From 9b32fa59a3b6e47bad0d85b9bf463c4f068bf9c0 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos <157128530+BillLeoutsakosvl346@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:24:52 -0700 Subject: [PATCH 01/29] feat(oci): add native foundation --- ...oci-api-key-service-account.server.test.ts | 357 ++++++++++++++++ .../oci-api-key-service-account.server.ts | 381 ++++++++++++++++++ .../lib/internal/oci/client.server.test.ts | 266 ++++++++++++ apps/sim/lib/internal/oci/client.server.ts | 129 ++++++ apps/sim/lib/internal/oci/endpoints.test.ts | 119 ++++++ apps/sim/lib/internal/oci/endpoints.ts | 254 ++++++++++++ apps/sim/lib/internal/oci/errors.ts | 59 +++ .../lib/internal/oci/signing.server.test.ts | 225 +++++++++++ apps/sim/lib/internal/oci/signing.server.ts | 104 +++++ apps/sim/lib/oauth/types.ts | 6 + apps/sim/package.json | 1 + bun.lock | 57 +++ 12 files changed, 1958 insertions(+) create mode 100644 apps/sim/lib/credentials/oci-api-key-service-account.server.test.ts create mode 100644 apps/sim/lib/credentials/oci-api-key-service-account.server.ts create mode 100644 apps/sim/lib/internal/oci/client.server.test.ts create mode 100644 apps/sim/lib/internal/oci/client.server.ts create mode 100644 apps/sim/lib/internal/oci/endpoints.test.ts create mode 100644 apps/sim/lib/internal/oci/endpoints.ts create mode 100644 apps/sim/lib/internal/oci/errors.ts create mode 100644 apps/sim/lib/internal/oci/signing.server.test.ts create mode 100644 apps/sim/lib/internal/oci/signing.server.ts diff --git a/apps/sim/lib/credentials/oci-api-key-service-account.server.test.ts b/apps/sim/lib/credentials/oci-api-key-service-account.server.test.ts new file mode 100644 index 00000000000..f9dc8f0afb4 --- /dev/null +++ b/apps/sim/lib/credentials/oci-api-key-service-account.server.test.ts @@ -0,0 +1,357 @@ +/** + * @vitest-environment node + */ +import { createHash, createPublicKey, generateKeyPairSync, type KeyObject } from 'node:crypto' +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const dependencies = vi.hoisted(() => { + const rows: Array<{ + type: string + providerId: string | null + encryptedServiceAccountKey: string | null + }> = [] + return { + rows, + decryptSecret: vi.fn(), + encryptSecret: vi.fn(), + sendOciRequest: vi.fn(), + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ limit: vi.fn(async () => rows) })), + })), + })), + } +}) + +vi.mock('@sim/db', () => ({ db: { select: dependencies.select } })) +vi.mock('@sim/db/schema', () => ({ + credential: { + id: 'credential.id', + type: 'credential.type', + providerId: 'credential.providerId', + encryptedServiceAccountKey: 'credential.encryptedServiceAccountKey', + }, +})) +vi.mock('drizzle-orm', () => ({ eq: vi.fn(() => 'predicate') })) +vi.mock('@/lib/core/security/encryption', () => ({ + decryptSecret: dependencies.decryptSecret, + encryptSecret: dependencies.encryptSecret, +})) +vi.mock('@/lib/internal/oci/client.server', () => ({ + sendOciRequest: dependencies.sendOciRequest, +})) + +import { + buildOciApiKeyServiceAccountSecret, + loadOciApiKeyCredential, + normalizeOciFingerprint, + OciCredentialVerificationError, + parseOciApiKeyServiceAccountSecret, + serializeOciApiKeyServiceAccountSecret, + verifyAndEncryptOciApiKeyCredential, + verifyOciApiKeyCredential, +} from '@/lib/credentials/oci-api-key-service-account.server' +import type { OciRequestResult } from '@/lib/internal/oci/client.server' +import { OciRequestError } from '@/lib/internal/oci/errors' +import { + OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID, + OCI_API_KEY_SERVICE_ACCOUNT_SECRET_TYPE, +} from '@/lib/oauth/types' + +const TENANCY_ID = 'ocid1.tenancy.oc1..aaaaaaaafoundationtenant' +const USER_ID = 'ocid1.user.oc1..aaaaaaaafoundationuser' + +function fingerprintForKey(privateKey: KeyObject): string { + const der = createPublicKey(privateKey).export({ format: 'der', type: 'spki' }) + return createHash('md5').update(der).digest('hex').match(/.{2}/g)!.join(':') +} + +function responseResult(body: string): OciRequestResult { + return { + response: { text: vi.fn().mockResolvedValue(body) } as unknown as OciRequestResult['response'], + } +} + +describe('OCI API-key credential foundation', () => { + let privateKeyObject: KeyObject + let privateKey: string + let fingerprint: string + let encryptedPrivateKey: string + const passphrase = ' exact passphrase ' + + beforeAll(() => { + privateKeyObject = generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey + privateKey = privateKeyObject.export({ format: 'pem', type: 'pkcs8' }).toString() + fingerprint = fingerprintForKey(privateKeyObject) + encryptedPrivateKey = privateKeyObject + .export({ + format: 'pem', + type: 'pkcs8', + cipher: 'aes-256-cbc', + passphrase, + }) + .toString() + }) + + beforeEach(() => { + dependencies.rows.splice(0) + dependencies.decryptSecret.mockReset() + dependencies.encryptSecret.mockReset() + dependencies.sendOciRequest.mockReset() + dependencies.select.mockClear() + }) + + function fields(overrides: Record = {}) { + return { + tenancyId: TENANCY_ID, + userId: USER_ID, + fingerprint, + privateKey, + defaultRegion: 'us-ashburn-1', + ...overrides, + } + } + + it('builds a normalized, versioned, provider-bound user-principal secret', () => { + const secret = buildOciApiKeyServiceAccountSecret( + fields({ fingerprint: fingerprint.toUpperCase().replaceAll(':', ' ') }) + ) + expect(secret).toEqual({ + type: OCI_API_KEY_SERVICE_ACCOUNT_SECRET_TYPE, + providerId: OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID, + tenancyId: TENANCY_ID, + userId: USER_ID, + fingerprint, + privateKey, + defaultRegion: 'us-ashburn-1', + metadata: { principalKind: 'user', principalId: USER_ID }, + }) + expect(secret).not.toHaveProperty('compartmentId') + expect(secret).not.toHaveProperty('namespace') + expect(secret).not.toHaveProperty('endpoint') + expect(secret).not.toHaveProperty('realm') + }) + + it('accepts encrypted RSA PEM only with the exact passphrase', () => { + expect( + buildOciApiKeyServiceAccountSecret(fields({ privateKey: encryptedPrivateKey, passphrase })) + .passphrase + ).toBe(passphrase) + expect(() => + buildOciApiKeyServiceAccountSecret(fields({ privateKey: encryptedPrivateKey })) + ).toThrow('private key or passphrase') + expect(() => + buildOciApiKeyServiceAccountSecret( + fields({ privateKey: encryptedPrivateKey, passphrase: passphrase.trim() }) + ) + ).toThrow('private key or passphrase') + }) + + it('rejects malformed, non-RSA, and undersized private keys', () => { + expect(() => buildOciApiKeyServiceAccountSecret(fields({ privateKey: 'not a key' }))).toThrow( + 'PEM encoded' + ) + const ecKey = generateKeyPairSync('ec', { namedCurve: 'prime256v1' }).privateKey + expect(() => + buildOciApiKeyServiceAccountSecret( + fields({ + privateKey: ecKey.export({ format: 'pem', type: 'pkcs8' }).toString(), + fingerprint: fingerprintForKey(ecKey), + }) + ) + ).toThrow('must use RSA') + const smallKey = generateKeyPairSync('rsa', { modulusLength: 1024 }).privateKey + expect(() => + buildOciApiKeyServiceAccountSecret( + fields({ + privateKey: smallKey.export({ format: 'pem', type: 'pkcs8' }).toString(), + fingerprint: fingerprintForKey(smallKey), + }) + ) + ).toThrow('at least 2048 bits') + }) + + it('normalizes fingerprints and compares them to the key', () => { + expect(normalizeOciFingerprint(` ${fingerprint.toUpperCase()} `)).toBe(fingerprint) + expect(normalizeOciFingerprint(fingerprint.replaceAll(':', ''))).toBe(fingerprint) + expect(() => normalizeOciFingerprint('aa:bb')).toThrow('16 MD5 bytes') + expect(() => + buildOciApiKeyServiceAccountSecret( + fields({ fingerprint: '00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00' }) + ) + ).toThrow('does not match') + }) + + it('enforces size and control-character limits', () => { + expect(() => + buildOciApiKeyServiceAccountSecret( + fields({ tenancyId: `ocid1.tenancy.oc1..${'a'.repeat(240)}` }) + ) + ).toThrow('tenancy OCID') + expect(() => buildOciApiKeyServiceAccountSecret(fields({ userId: `${USER_ID}\n` }))).toThrow( + 'user OCID' + ) + expect(() => + buildOciApiKeyServiceAccountSecret(fields({ privateKey: `${privateKey}\u0000` })) + ).toThrow('private key') + expect(() => + buildOciApiKeyServiceAccountSecret(fields({ passphrase: 'x'.repeat(4097) })) + ).toThrow('passphrase') + expect(() => buildOciApiKeyServiceAccountSecret(fields({ passphrase: 'line\nbreak' }))).toThrow( + 'passphrase' + ) + }) + + it('enforces OCID resource type, realm matching, and region membership', () => { + expect(() => buildOciApiKeyServiceAccountSecret(fields({ tenancyId: USER_ID }))).toThrow( + 'wrong structure or resource type' + ) + expect(() => + buildOciApiKeyServiceAccountSecret( + fields({ userId: 'ocid1.user.oc2..aaaaaaaafoundationuser' }) + ) + ).toThrow('share a realm') + expect(() => + buildOciApiKeyServiceAccountSecret(fields({ defaultRegion: 'unknown-region-1' })) + ).toThrow('not recognized') + expect(() => + buildOciApiKeyServiceAccountSecret(fields({ defaultRegion: 'us-gov-ashburn-1' })) + ).toThrow('credential realm') + expect(() => + buildOciApiKeyServiceAccountSecret( + fields({ + tenancyId: 'ocid1.tenancy.oc99..aaaaaaaafoundationtenant', + userId: 'ocid1.user.oc99..aaaaaaaafoundationuser', + }) + ) + ).toThrow('credential realm') + }) + + it('strictly parses only canonical version-one secrets', () => { + const secret = buildOciApiKeyServiceAccountSecret(fields()) + const serialized = serializeOciApiKeyServiceAccountSecret(secret) + expect(parseOciApiKeyServiceAccountSecret(serialized)).toEqual(secret) + expect(() => + parseOciApiKeyServiceAccountSecret(JSON.stringify({ ...secret, compartmentId: TENANCY_ID })) + ).toThrow('malformed') + expect(() => + parseOciApiKeyServiceAccountSecret( + JSON.stringify({ ...secret, providerId: 'another-provider' }) + ) + ).toThrow('malformed') + expect(() => + parseOciApiKeyServiceAccountSecret( + JSON.stringify({ + ...secret, + metadata: { principalKind: 'tenant', principalId: TENANCY_ID }, + }) + ) + ).toThrow('malformed') + expect(() => + parseOciApiKeyServiceAccountSecret( + JSON.stringify({ ...secret, defaultRegion: ' US-ASHBURN-1 ' }) + ) + ).toThrow('malformed') + expect(() => + parseOciApiKeyServiceAccountSecret(JSON.stringify({ ...secret, tenancyId: null })) + ).toThrow('malformed') + }) + + it('verifies with the exact permissionless GetNamespace request and forwards bounds', async () => { + const secret = buildOciApiKeyServiceAccountSecret(fields()) + const controller = new AbortController() + dependencies.sendOciRequest.mockResolvedValue(responseResult('"tenant-namespace"')) + await expect(verifyOciApiKeyCredential(secret, controller.signal)).resolves.toEqual({ + namespace: 'tenant-namespace', + }) + expect(dependencies.sendOciRequest).toHaveBeenCalledWith({ + destination: expect.objectContaining({ + origin: 'https://objectstorage.us-ashburn-1.oraclecloud.com', + }), + credentials: secret, + method: 'GET', + encodedPath: '/n/', + timeout: 10_000, + maxResponseBytes: 64 * 1024, + signal: controller.signal, + serviceHeaders: { accept: 'application/json' }, + }) + expect(dependencies.sendOciRequest.mock.calls[0][0]).not.toHaveProperty('queryPairs') + expect(dependencies.sendOciRequest.mock.calls[0][0]).not.toHaveProperty('compartmentId') + }) + + it('maps authentication, malformed-response, and transient failures to secret-safe errors', async () => { + const secret = buildOciApiKeyServiceAccountSecret(fields({ passphrase: 'very-secret' })) + const cases = [ + { + failure: new OciRequestError({ + status: 401, + message: `echo ${privateKey} very-secret`, + }), + code: 'invalid_credentials', + }, + { failure: responseResult('{malformed'), code: 'invalid_response' }, + { failure: new Error(`temporary ${privateKey} very-secret`), code: 'service_unavailable' }, + ] as const + for (const testCase of cases) { + if (testCase.failure instanceof Error) { + dependencies.sendOciRequest.mockRejectedValueOnce(testCase.failure) + } else { + dependencies.sendOciRequest.mockResolvedValueOnce(testCase.failure) + } + const failure = await verifyOciApiKeyCredential(secret).catch((error: unknown) => error) + expect(failure).toBeInstanceOf(OciCredentialVerificationError) + expect((failure as OciCredentialVerificationError).code).toBe(testCase.code) + expect((failure as Error).message).not.toContain('very-secret') + expect((failure as Error).message).not.toContain('BEGIN PRIVATE KEY') + } + }) + + it('encrypts only after local validation and remote verification succeed', async () => { + const order: string[] = [] + dependencies.sendOciRequest.mockImplementation(async () => { + order.push('verify') + return responseResult('"namespace"') + }) + dependencies.encryptSecret.mockImplementation(async () => { + order.push('encrypt') + return { encrypted: 'ciphertext', iv: 'iv' } + }) + await expect(verifyAndEncryptOciApiKeyCredential(fields())).resolves.toEqual({ + encryptedServiceAccountKey: 'ciphertext', + namespace: 'namespace', + }) + expect(order).toEqual(['verify', 'encrypt']) + + dependencies.sendOciRequest.mockClear() + dependencies.encryptSecret.mockClear() + await expect( + verifyAndEncryptOciApiKeyCredential(fields({ fingerprint: 'invalid' })) + ).rejects.toThrow() + expect(dependencies.sendOciRequest).not.toHaveBeenCalled() + expect(dependencies.encryptSecret).not.toHaveBeenCalled() + }) + + it('checks both outer and inner provider binding before returning decrypted material', async () => { + dependencies.rows.push({ + type: 'service_account', + providerId: 'another-provider', + encryptedServiceAccountKey: 'ciphertext', + }) + dependencies.decryptSecret.mockResolvedValue({ decrypted: 'should-not-be-read' }) + await expect(loadOciApiKeyCredential('credential-1')).rejects.toThrow('provider-mismatched') + expect(dependencies.decryptSecret).not.toHaveBeenCalled() + + const secret = buildOciApiKeyServiceAccountSecret(fields()) + dependencies.rows.splice(0) + dependencies.rows.push({ + type: 'service_account', + providerId: OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID, + encryptedServiceAccountKey: 'ciphertext', + }) + dependencies.decryptSecret.mockResolvedValueOnce({ + decrypted: JSON.stringify({ ...secret, providerId: 'another-provider' }), + }) + await expect(loadOciApiKeyCredential('credential-1')).rejects.toThrow('malformed') + }) +}) diff --git a/apps/sim/lib/credentials/oci-api-key-service-account.server.ts b/apps/sim/lib/credentials/oci-api-key-service-account.server.ts new file mode 100644 index 00000000000..6f0f7de6d38 --- /dev/null +++ b/apps/sim/lib/credentials/oci-api-key-service-account.server.ts @@ -0,0 +1,381 @@ +import { createHash, createPrivateKey, createPublicKey } from 'node:crypto' +import { db } from '@sim/db' +import { credential } from '@sim/db/schema' +import { safeCompare } from '@sim/security/compare' +import { eq } from 'drizzle-orm' +import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' +import { serviceAccountPrincipalMetadata } from '@/lib/credentials/principal' +import { sendOciRequest } from '@/lib/internal/oci/client.server' +import { + getOciRegion, + objectStorageOciDestination, + resolveEffectiveOciRegion, +} from '@/lib/internal/oci/endpoints' +import { OciRequestError } from '@/lib/internal/oci/errors' +import type { OciSigningCredentials } from '@/lib/internal/oci/signing.server' +import { + OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID, + OCI_API_KEY_SERVICE_ACCOUNT_SECRET_TYPE, +} from '@/lib/oauth/types' + +const MAX_OCID_LENGTH = 255 +const MAX_PRIVATE_KEY_BYTES = 64 * 1024 +const MAX_PASSPHRASE_BYTES = 4 * 1024 +const OCI_VERIFICATION_TIMEOUT_MS = 10_000 +const OCI_VERIFICATION_RESPONSE_BYTES = 64 * 1024 +const OCID_PATTERN = /^ocid1\.([a-z][a-z0-9_-]*)\.([a-z0-9]+)\.([a-z0-9-]*)\.([a-zA-Z0-9_-]+)$/ +const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f]/ +const PEM_CONTROL_CHARACTER_PATTERN = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/ + +export interface OciApiKeyCredentialFields { + tenancyId: string + userId: string + fingerprint: string + privateKey: string + passphrase?: string + defaultRegion: string +} + +export interface OciApiKeyServiceAccountSecret extends OciSigningCredentials { + readonly type: typeof OCI_API_KEY_SERVICE_ACCOUNT_SECRET_TYPE + readonly providerId: typeof OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID + readonly defaultRegion: string + readonly metadata: { + readonly principalKind: 'user' + readonly principalId: string + } +} + +export type OciCredentialVerificationCode = + | 'invalid_credentials' + | 'invalid_response' + | 'service_unavailable' + +/** Safe error categories for credential verification callers. */ +export class OciCredentialVerificationError extends Error { + constructor(public readonly code: OciCredentialVerificationCode) { + super(code) + this.name = 'OciCredentialVerificationError' + } +} + +function assertBoundedText( + value: unknown, + field: string, + maxBytes: number, + controlPattern = CONTROL_CHARACTER_PATTERN +): asserts value is string { + if ( + typeof value !== 'string' || + value.length === 0 || + Buffer.byteLength(value, 'utf8') > maxBytes || + controlPattern.test(value) + ) { + throw new Error(`OCI ${field} is invalid`) + } +} + +function normalizeOcid( + value: unknown, + expectedType: 'tenancy' | 'user' +): { + value: string + realmId: string +} { + assertBoundedText(value, `${expectedType} OCID`, MAX_OCID_LENGTH) + const normalized = value.trim() + const match = OCID_PATTERN.exec(normalized) + if (!match || match[1] !== expectedType) { + throw new Error(`OCI ${expectedType} OCID has the wrong structure or resource type`) + } + return { value: normalized, realmId: match[2] } +} + +export function normalizeOciFingerprint(value: unknown): string { + assertBoundedText(value, 'fingerprint', 128) + const hex = value.replace(/[:\s]/g, '').toLowerCase() + if (!/^[0-9a-f]{32}$/.test(hex)) throw new Error('OCI fingerprint must contain 16 MD5 bytes') + const bytes = hex.match(/.{2}/g) + if (!bytes) throw new Error('OCI fingerprint must contain 16 MD5 bytes') + return bytes.join(':') +} + +function normalizePrivateKey(value: unknown): string { + assertBoundedText(value, 'private key', MAX_PRIVATE_KEY_BYTES, PEM_CONTROL_CHARACTER_PATTERN) + const normalized = value.replace(/\r\n?/g, '\n').trim() + if (!normalized.startsWith('-----BEGIN ') || !normalized.endsWith('-----')) { + throw new Error('OCI private key must be PEM encoded') + } + return `${normalized}\n` +} + +function validatePassphrase(value: unknown): string | undefined { + if (value === undefined) return undefined + if ( + typeof value !== 'string' || + Buffer.byteLength(value, 'utf8') > MAX_PASSPHRASE_BYTES || + CONTROL_CHARACTER_PATTERN.test(value) + ) { + throw new Error('OCI private-key passphrase is invalid') + } + return value +} + +function validatePrivateKeyAndFingerprint(params: { + privateKey: string + passphrase?: string + fingerprint: string +}): void { + let key + try { + key = createPrivateKey({ + key: params.privateKey, + format: 'pem', + ...(params.passphrase !== undefined ? { passphrase: params.passphrase } : {}), + }) + } catch { + throw new Error('OCI private key or passphrase is invalid') + } + if (key.asymmetricKeyType !== 'rsa') throw new Error('OCI private key must use RSA') + const modulusLength = key.asymmetricKeyDetails?.modulusLength + if (modulusLength === undefined || modulusLength < 2048) { + throw new Error('OCI RSA private key must be at least 2048 bits') + } + const spki = createPublicKey(key).export({ format: 'der', type: 'spki' }) + const derivedHex = createHash('md5').update(spki).digest('hex') + const submittedHex = params.fingerprint.replaceAll(':', '') + const fingerprintsMatch = safeCompare( + Buffer.from(derivedHex, 'hex').toString('base64'), + Buffer.from(submittedHex, 'hex').toString('base64') + ) + if (!fingerprintsMatch) throw new Error('OCI fingerprint does not match the private key') +} + +/** Validates and normalizes credential fields without performing I/O. */ +export function buildOciApiKeyServiceAccountSecret( + fields: OciApiKeyCredentialFields +): OciApiKeyServiceAccountSecret { + const tenancy = normalizeOcid(fields.tenancyId, 'tenancy') + const user = normalizeOcid(fields.userId, 'user') + if (tenancy.realmId !== user.realmId) + throw new Error('OCI tenancy and user OCIDs must share a realm') + + assertBoundedText(fields.defaultRegion, 'default region', 128) + const defaultRegion = fields.defaultRegion.trim().toLowerCase() + const region = getOciRegion(defaultRegion) + if (region.realm.id !== tenancy.realmId) { + throw new Error('OCI default region must belong to the credential realm') + } + + const fingerprint = normalizeOciFingerprint(fields.fingerprint) + const privateKey = normalizePrivateKey(fields.privateKey) + const passphrase = validatePassphrase(fields.passphrase) + validatePrivateKeyAndFingerprint({ privateKey, passphrase, fingerprint }) + const metadata = serviceAccountPrincipalMetadata({ kind: 'user', id: user.value }) + + return { + type: OCI_API_KEY_SERVICE_ACCOUNT_SECRET_TYPE, + providerId: OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID, + tenancyId: tenancy.value, + userId: user.value, + fingerprint, + privateKey, + ...(passphrase !== undefined ? { passphrase } : {}), + defaultRegion, + metadata: { principalKind: 'user', principalId: metadata.principalId }, + } +} + +export function serializeOciApiKeyServiceAccountSecret( + secret: OciApiKeyServiceAccountSecret +): string { + return JSON.stringify(secret) +} + +function assertExactKeys( + record: Record, + required: readonly string[], + optional: readonly string[] = [] +): void { + const keys = Object.keys(record) + if ( + required.some((key) => !Object.hasOwn(record, key)) || + keys.some((key) => !required.includes(key) && !optional.includes(key)) + ) { + throw new Error('Stored OCI API-key credential is malformed') + } +} + +/** Strictly parses and revalidates an encrypted OCI credential payload. */ +export function parseOciApiKeyServiceAccountSecret( + serialized: string, + expectedProviderId: string = OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID +): OciApiKeyServiceAccountSecret { + let parsed: unknown + try { + parsed = JSON.parse(serialized) + } catch { + throw new Error('Stored OCI API-key credential is malformed') + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('Stored OCI API-key credential is malformed') + } + const record = parsed as Record + assertExactKeys( + record, + [ + 'type', + 'providerId', + 'tenancyId', + 'userId', + 'fingerprint', + 'privateKey', + 'defaultRegion', + 'metadata', + ], + ['passphrase'] + ) + if ( + record.type !== OCI_API_KEY_SERVICE_ACCOUNT_SECRET_TYPE || + record.providerId !== expectedProviderId || + expectedProviderId !== OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID || + !record.metadata || + typeof record.metadata !== 'object' || + Array.isArray(record.metadata) + ) { + throw new Error('Stored OCI API-key credential is malformed') + } + const metadata = record.metadata as Record + assertExactKeys(metadata, ['principalKind', 'principalId']) + let passphrase: string | undefined + if (Object.hasOwn(record, 'passphrase')) { + if (typeof record.passphrase !== 'string') { + throw new Error('Stored OCI API-key credential is malformed') + } + passphrase = record.passphrase + } + if ( + typeof record.tenancyId !== 'string' || + typeof record.userId !== 'string' || + typeof record.fingerprint !== 'string' || + typeof record.privateKey !== 'string' || + typeof record.defaultRegion !== 'string' + ) { + throw new Error('Stored OCI API-key credential is malformed') + } + let rebuilt: OciApiKeyServiceAccountSecret + try { + rebuilt = buildOciApiKeyServiceAccountSecret({ + tenancyId: record.tenancyId, + userId: record.userId, + fingerprint: record.fingerprint, + privateKey: record.privateKey, + ...(passphrase !== undefined ? { passphrase } : {}), + defaultRegion: record.defaultRegion, + }) + } catch { + throw new Error('Stored OCI API-key credential is malformed') + } + if ( + metadata.principalKind !== 'user' || + metadata.principalId !== rebuilt.userId || + record.tenancyId !== rebuilt.tenancyId || + record.userId !== rebuilt.userId || + record.fingerprint !== rebuilt.fingerprint || + record.privateKey !== rebuilt.privateKey || + record.defaultRegion !== rebuilt.defaultRegion || + record.passphrase !== rebuilt.passphrase + ) { + throw new Error('Stored OCI API-key credential is malformed') + } + return rebuilt +} + +/** Verifies a locally valid credential with Object Storage GetNamespace. */ +export async function verifyOciApiKeyCredential( + secret: OciApiKeyServiceAccountSecret, + signal?: AbortSignal +): Promise<{ namespace: string }> { + const region = resolveEffectiveOciRegion(secret.defaultRegion) + try { + const result = await sendOciRequest({ + destination: objectStorageOciDestination(region), + credentials: secret, + method: 'GET', + encodedPath: '/n/', + timeout: OCI_VERIFICATION_TIMEOUT_MS, + maxResponseBytes: OCI_VERIFICATION_RESPONSE_BYTES, + signal, + serviceHeaders: { accept: 'application/json' }, + }) + const parsed: unknown = JSON.parse(await result.response.text()) + if ( + typeof parsed !== 'string' || + parsed.length === 0 || + Buffer.byteLength(parsed, 'utf8') > 255 || + CONTROL_CHARACTER_PATTERN.test(parsed) + ) { + throw new OciCredentialVerificationError('invalid_response') + } + return { namespace: parsed } + } catch (error) { + if (error instanceof OciCredentialVerificationError) throw error + if (signal?.aborted) throw error + if (error instanceof OciRequestError && (error.status === 401 || error.status === 403)) { + throw new OciCredentialVerificationError('invalid_credentials') + } + if (error instanceof SyntaxError) { + throw new OciCredentialVerificationError('invalid_response') + } + throw new OciCredentialVerificationError('service_unavailable') + } +} + +/** Validates, verifies, then encrypts an OCI credential in that order. */ +export async function verifyAndEncryptOciApiKeyCredential( + fields: OciApiKeyCredentialFields, + signal?: AbortSignal +): Promise<{ encryptedServiceAccountKey: string; namespace: string }> { + const secret = buildOciApiKeyServiceAccountSecret(fields) + const { namespace } = await verifyOciApiKeyCredential(secret, signal) + const { encrypted } = await encryptSecret(serializeOciApiKeyServiceAccountSecret(secret)) + return { encryptedServiceAccountKey: encrypted, namespace } +} + +interface OciCredentialRowProjection { + type: string + providerId: string | null + encryptedServiceAccountKey: string | null +} + +async function findOciCredentialById( + credentialId: string +): Promise { + const [row] = await db + .select({ + type: credential.type, + providerId: credential.providerId, + encryptedServiceAccountKey: credential.encryptedServiceAccountKey, + }) + .from(credential) + .where(eq(credential.id, credentialId)) + .limit(1) + return row ?? null +} + +/** Loads one provider-bound OCI credential, checking outer binding before decryption. */ +export async function loadOciApiKeyCredential( + credentialId: string +): Promise { + const row = await findOciCredentialById(credentialId) + if ( + !row || + row.type !== 'service_account' || + row.providerId !== OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID || + !row.encryptedServiceAccountKey + ) { + throw new Error('OCI API-key credential is unavailable or provider-mismatched') + } + const { decrypted } = await decryptSecret(row.encryptedServiceAccountKey) + return parseOciApiKeyServiceAccountSecret(decrypted, row.providerId) +} diff --git a/apps/sim/lib/internal/oci/client.server.test.ts b/apps/sim/lib/internal/oci/client.server.test.ts new file mode 100644 index 00000000000..4190166cd0d --- /dev/null +++ b/apps/sim/lib/internal/oci/client.server.test.ts @@ -0,0 +1,266 @@ +/** + * @vitest-environment node + */ +import { generateKeyPairSync } from 'node:crypto' +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const secureFetchMock = vi.hoisted(() => vi.fn()) + +vi.mock('@/lib/core/security/input-validation.server', () => ({ + DEFAULT_MAX_RESPONSE_BYTES: 100 * 1024 * 1024, + secureFetchWithValidation: secureFetchMock, +})) + +import { + buildOciRequestUrl, + sendOciRequest, + serializeOciQueryPairs, +} from '@/lib/internal/oci/client.server' +import { getOciRegion, objectStorageOciDestination } from '@/lib/internal/oci/endpoints' +import { OciRequestError } from '@/lib/internal/oci/errors' +import type { OciSigningCredentials } from '@/lib/internal/oci/signing.server' + +function secureResponse(params: { + ok: boolean + status: number + body?: string + opcRequestId?: string +}) { + return { + ok: params.ok, + status: params.status, + statusText: '', + headers: { + get: (name: string) => + name.toLowerCase() === 'opc-request-id' ? (params.opcRequestId ?? null) : null, + }, + body: null, + text: vi.fn().mockResolvedValue(params.body ?? ''), + json: vi.fn(), + arrayBuffer: vi.fn(), + } +} + +describe('OCI request client', () => { + let credentials: OciSigningCredentials + const destination = objectStorageOciDestination(getOciRegion('us-ashburn-1')) + + beforeAll(() => { + const pair = generateKeyPairSync('rsa', { modulusLength: 2048 }) + credentials = { + tenancyId: 'ocid1.tenancy.oc1..clienttest', + userId: 'ocid1.user.oc1..clienttest', + fingerprint: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff', + privateKey: pair.privateKey.export({ format: 'pem', type: 'pkcs8' }).toString(), + passphrase: 'client-secret-passphrase', + } + }) + + beforeEach(() => { + secureFetchMock.mockReset() + secureFetchMock.mockResolvedValue(secureResponse({ ok: true, status: 200 })) + }) + + it('serializes ordered duplicate and Unicode query pairs with RFC 3986 encoding', () => { + expect( + serializeOciQueryPairs([ + ['z', 'last'], + ['a', 'one'], + ['a', ''], + ['space', 'a b'], + ['unicode', '☃'], + ["!'()*", "!'()*"], + ]) + ).toBe('z=last&a=one&a=&space=a%20b&unicode=%E2%98%83&%21%27%28%29%2A=%21%27%28%29%2A') + }) + + it('transmits the exact URL, finalized body, and headers that were signed', async () => { + const body = '{"message":"héllo ☃"}' + await sendOciRequest({ + destination, + credentials, + method: 'POST', + encodedPath: '/n/tenant/b', + queryPairs: [ + ['z', 'last'], + ['a', 'one'], + ['a', ''], + ['unicode', '☃'], + ], + timeout: 12_345, + maxResponseBytes: 54_321, + serviceHeaders: { accept: 'application/json', 'opc-retry-token': 'fixed-token' }, + body, + }) + + expect(secureFetchMock).toHaveBeenCalledOnce() + const [url, options, paramName] = secureFetchMock.mock.calls[0] + expect(url).toBe( + 'https://objectstorage.us-ashburn-1.oraclecloud.com/n/tenant/b?z=last&a=one&a=&unicode=%E2%98%83' + ) + expect(paramName).toBe('OCI destination') + expect(options).toMatchObject({ + method: 'POST', + body, + timeout: 12_345, + maxResponseBytes: 54_321, + maxRedirects: 0, + profile: 'configuredEndpoint', + logUrlValidationDetails: false, + }) + expect(options.headers.accept).toBe('application/json') + expect(options.headers['opc-retry-token']).toBe('fixed-token') + expect(options.headers.authorization).toContain('Signature version="1"') + expect(options.headers['content-length']).toBe(String(Buffer.byteLength(body, 'utf8'))) + expect(options.headers).not.toHaveProperty('date') + }) + + it('forwards cancellation and always disables redirects', async () => { + const controller = new AbortController() + await sendOciRequest({ + destination, + credentials, + method: 'GET', + encodedPath: '/n/', + timeout: 10_000, + maxResponseBytes: 65_536, + signal: controller.signal, + }) + expect(secureFetchMock.mock.calls[0][1]).toMatchObject({ + signal: controller.signal, + timeout: 10_000, + maxResponseBytes: 65_536, + maxRedirects: 0, + }) + }) + + it('returns bounded successful responses and the OCI request id without imposing a schema', async () => { + const response = secureResponse({ + ok: true, + status: 202, + body: 'service-specific bytes', + opcRequestId: 'request-123', + }) + secureFetchMock.mockResolvedValueOnce(response) + const result = await sendOciRequest({ + destination, + credentials, + method: 'GET', + encodedPath: '/n/', + timeout: 10_000, + maxResponseBytes: 65_536, + }) + expect(result).toEqual({ response, opcRequestId: 'request-123' }) + expect(response.text).not.toHaveBeenCalled() + }) + + it('retains bounded OCI error fields and request ids while redacting echoed secrets', async () => { + const echoedUrl = 'https://objectstorage.us-ashburn-1.oraclecloud.com/n/' + secureFetchMock.mockResolvedValueOnce( + secureResponse({ + ok: false, + status: 401, + opcRequestId: 'request-401', + body: JSON.stringify({ + code: 'NotAuthenticated', + message: `provider echoed ${credentials.passphrase} ${credentials.privateKey} ${echoedUrl}\n`, + }), + }) + ) + const failure = await sendOciRequest({ + destination, + credentials, + method: 'GET', + encodedPath: '/n/', + timeout: 10_000, + maxResponseBytes: 65_536, + }).catch((error: unknown) => error) + expect(failure).toBeInstanceOf(OciRequestError) + expect(failure).toMatchObject({ + status: 401, + code: 'NotAuthenticated', + opcRequestId: 'request-401', + }) + expect((failure as Error).message).toContain('[redacted]') + expect((failure as Error).message).not.toContain('client-secret-passphrase') + expect((failure as Error).message).not.toContain('BEGIN PRIVATE KEY') + expect((failure as Error).message).not.toContain('objectstorage.us-ashburn-1') + expect((failure as Error).message.length).toBeLessThanOrEqual(1050) + }) + + it('does not expose malformed response bodies or signed request details', async () => { + secureFetchMock.mockResolvedValueOnce( + secureResponse({ + ok: false, + status: 502, + opcRequestId: 'request-502', + body: `${credentials.privateKey}`, + }) + ) + const failure = await sendOciRequest({ + destination, + credentials, + method: 'GET', + encodedPath: '/n/', + timeout: 10_000, + maxResponseBytes: 65_536, + }).catch((error: unknown) => error) + expect(failure).toBeInstanceOf(OciRequestError) + expect((failure as Error).message).toBe('OCI request failed with status 502') + expect((failure as OciRequestError).opcRequestId).toBe('request-502') + }) + + it.each([ + '//attacker.example/path', + '/safe//attacker', + '/path?injected=true', + '/path#fragment', + '/path\\replacement', + '/path%ZZ', + ])('rejects unsafe encoded paths: %s', (encodedPath) => { + expect(() => buildOciRequestUrl(destination, encodedPath)).toThrow( + 'single encoded absolute path' + ) + }) + + it('rejects invalid transport bounds before signing or sending', async () => { + for (const invalid of [0, -1, Number.NaN, 300_001]) { + await expect( + sendOciRequest({ + destination, + credentials, + method: 'GET', + encodedPath: '/n/', + timeout: invalid, + maxResponseBytes: 65_536, + }) + ).rejects.toThrow('timeout') + } + await expect( + sendOciRequest({ + destination, + credentials, + method: 'GET', + encodedPath: '/n/', + timeout: 10_000, + maxResponseBytes: 100 * 1024 * 1024 + 1, + }) + ).rejects.toThrow('response ceiling') + expect(secureFetchMock).not.toHaveBeenCalled() + }) + + it('propagates a bounded response-ceiling failure without adding request material', async () => { + secureFetchMock.mockRejectedValueOnce(new Error('Response exceeded the configured byte limit')) + const failure = await sendOciRequest({ + destination, + credentials, + method: 'GET', + encodedPath: '/n/', + timeout: 10_000, + maxResponseBytes: 64, + }).catch((error: unknown) => error) + expect((failure as Error).message).toBe('Response exceeded the configured byte limit') + expect((failure as Error).message).not.toContain('authorization') + expect((failure as Error).message).not.toContain(destination.hostname) + }) +}) diff --git a/apps/sim/lib/internal/oci/client.server.ts b/apps/sim/lib/internal/oci/client.server.ts new file mode 100644 index 00000000000..f6a32b91908 --- /dev/null +++ b/apps/sim/lib/internal/oci/client.server.ts @@ -0,0 +1,129 @@ +import { + DEFAULT_MAX_RESPONSE_BYTES, + type SecureFetchResponse, + secureFetchWithValidation, +} from '@/lib/core/security/input-validation.server' +import type { ValidatedOciDestination } from '@/lib/internal/oci/endpoints' +import { OciRequestError, parseOciErrorBody } from '@/lib/internal/oci/errors' +import { + type OciRequestMethod, + type OciSigningCredentials, + signOciRequest, +} from '@/lib/internal/oci/signing.server' + +const MAX_OCI_TIMEOUT_MS = 5 * 60 * 1000 + +export interface OciRequestResult { + readonly response: SecureFetchResponse + readonly opcRequestId?: string +} + +function encodeRfc3986(value: string): string { + return encodeURIComponent(value).replace( + /[!'()*]/g, + (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}` + ) +} + +export function serializeOciQueryPairs(pairs: readonly (readonly [string, string])[]): string { + return pairs.map(([key, value]) => `${encodeRfc3986(key)}=${encodeRfc3986(value)}`).join('&') +} + +export function buildOciRequestUrl( + destination: ValidatedOciDestination, + encodedPath: string, + queryPairs: readonly (readonly [string, string])[] = [] +): string { + if ( + !encodedPath.startsWith('/') || + encodedPath.startsWith('//') || + encodedPath.includes('//') || + /[?#\\\u0000-\u001f\u007f]/.test(encodedPath) || + /%(?![0-9a-f]{2})/i.test(encodedPath) + ) { + throw new Error('OCI request path must be a single encoded absolute path') + } + const query = serializeOciQueryPairs(queryPairs) + return `${destination.origin}${encodedPath}${query ? `?${query}` : ''}` +} + +function validateRequestLimits(timeout: number, maxResponseBytes: number): void { + if (!Number.isSafeInteger(timeout) || timeout <= 0 || timeout > MAX_OCI_TIMEOUT_MS) { + throw new Error('OCI timeout is outside the supported range') + } + if ( + !Number.isSafeInteger(maxResponseBytes) || + maxResponseBytes <= 0 || + maxResponseBytes > DEFAULT_MAX_RESPONSE_BYTES + ) { + throw new Error('OCI response ceiling is outside the supported range') + } +} + +function sensitiveRequestValues( + credentials: OciSigningCredentials, + authorization: string | undefined +): string[] { + return [ + credentials.tenancyId, + credentials.userId, + credentials.fingerprint, + credentials.privateKey, + credentials.passphrase ?? '', + authorization ?? '', + ].filter(Boolean) +} + +/** Sends one bounded, redirect-free OCI request to an already validated destination. */ +export async function sendOciRequest(params: { + destination: ValidatedOciDestination + credentials: OciSigningCredentials + method: OciRequestMethod + encodedPath: string + queryPairs?: readonly (readonly [string, string])[] + timeout: number + maxResponseBytes: number + signal?: AbortSignal + serviceHeaders?: Readonly> + body?: string + contentType?: string +}): Promise { + validateRequestLimits(params.timeout, params.maxResponseBytes) + const url = buildOciRequestUrl(params.destination, params.encodedPath, params.queryPairs) + const signed = await signOciRequest({ + credentials: params.credentials, + method: params.method, + url, + serviceHeaders: params.serviceHeaders, + body: params.body, + contentType: params.contentType, + }) + const response = await secureFetchWithValidation( + signed.url, + { + method: signed.method, + headers: { ...signed.headers }, + ...(signed.body !== undefined ? { body: signed.body } : {}), + timeout: params.timeout, + maxResponseBytes: params.maxResponseBytes, + maxRedirects: 0, + signal: params.signal, + profile: 'configuredEndpoint', + logUrlValidationDetails: false, + }, + 'OCI destination' + ) + const opcRequestId = response.headers.get('opc-request-id') ?? undefined + if (response.ok) return { response, opcRequestId } + + const sensitiveValues = sensitiveRequestValues(params.credentials, signed.headers.authorization) + const body = await response.text() + const error = parseOciErrorBody(body, sensitiveValues) + throw new OciRequestError({ + status: response.status, + code: error.code, + message: error.message, + opcRequestId, + sensitiveValues, + }) +} diff --git a/apps/sim/lib/internal/oci/endpoints.test.ts b/apps/sim/lib/internal/oci/endpoints.test.ts new file mode 100644 index 00000000000..cd789b00f25 --- /dev/null +++ b/apps/sim/lib/internal/oci/endpoints.test.ts @@ -0,0 +1,119 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + getOciRegion, + isObjectStorageOciHostname, + OCI_REGION_IDS, + objectStorageOciDestination, + objectStorageOciHostname, + resolveEffectiveOciRegion, + validateOciDestination, +} from '@/lib/internal/oci/endpoints' + +describe('OCI region registry', () => { + it('resolves every snapshotted entry to a consistent realm and domain', () => { + expect(OCI_REGION_IDS.length).toBeGreaterThan(80) + for (const id of OCI_REGION_IDS) { + const region = getOciRegion(id) + expect(region.id).toBe(id) + expect(region.realm.id).toMatch(/^oc\d+$/) + expect(region.realm.domain).toMatch(/^(?:oraclecloud|oraclegovcloud)/) + expect(objectStorageOciHostname(region)).toBe(`objectstorage.${id}.${region.realm.domain}`) + } + }) + + it('normalizes known regions and fails closed for unknown regions', () => { + expect(getOciRegion(' US-ASHBURN-1 ').id).toBe('us-ashburn-1') + expect(() => getOciRegion('moon-base-1')).toThrow('not recognized') + }) + + it('allows only same-realm effective-region overrides', () => { + expect(resolveEffectiveOciRegion('us-ashburn-1').id).toBe('us-ashburn-1') + expect(resolveEffectiveOciRegion('us-ashburn-1', 'eu-frankfurt-1').id).toBe('eu-frankfurt-1') + expect(() => resolveEffectiveOciRegion('us-ashburn-1', 'us-gov-ashburn-1')).toThrow( + 'credential realm' + ) + expect(() => resolveEffectiveOciRegion('us-ashburn-1', 'unknown-1')).toThrow('not recognized') + }) +}) + +describe('validateOciDestination', () => { + const region = getOciRegion('us-ashburn-1') + const origin = 'https://objectstorage.us-ashburn-1.oraclecloud.com' + + it.each(['static', 'authenticated-discovery'] as const)( + 'brands a service-owned %s destination', + (provenance) => { + expect(objectStorageOciDestination(region, provenance)).toMatchObject({ + origin, + hostname: 'objectstorage.us-ashburn-1.oraclecloud.com', + service: 'objectstorage', + region, + provenance, + }) + } + ) + + it.each([ + 'http://objectstorage.us-ashburn-1.oraclecloud.com', + 'https://objectstorage.us-ashburn-1.oraclecloud.com:8443', + 'https://user@objectstorage.us-ashburn-1.oraclecloud.com', + 'https://objectstorage.us-ashburn-1.oraclecloud.com/path', + 'https://objectstorage.us-ashburn-1.oraclecloud.com?query=1', + 'https://objectstorage.us-ashburn-1.oraclecloud.com#fragment', + 'https://127.0.0.1', + ])('rejects a non-origin destination: %s', (candidate) => { + expect(() => + validateOciDestination({ + origin: candidate, + service: 'objectstorage', + region, + provenance: 'static', + isServiceHostname: isObjectStorageOciHostname, + }) + ).toThrow() + }) + + it.each([ + 'https://identity.us-ashburn-1.oraclecloud.com', + 'https://objectstorage.eu-frankfurt-1.oraclecloud.com', + 'https://objectstorage.us-ashburn-1.oraclegovcloud.com', + 'https://objectstorage.us-ashburn-1.example.com', + ])('rejects a hostname outside the service and effective region: %s', (candidate) => { + expect(() => + validateOciDestination({ + origin: candidate, + service: 'objectstorage', + region, + provenance: 'authenticated-discovery', + isServiceHostname: isObjectStorageOciHostname, + }) + ).toThrow('not owned') + }) + + it('binds the hostname predicate to its service constant', () => { + expect(() => + validateOciDestination({ + origin, + service: 'identity', + region, + provenance: 'static', + isServiceHostname: isObjectStorageOciHostname, + }) + ).toThrow('not owned') + }) + + it('rejects a forged region-to-realm association', () => { + expect(() => + validateOciDestination({ + origin, + service: 'objectstorage', + region: { id: region.id, realm: { id: 'oc2', domain: 'oraclegovcloud.com' } }, + provenance: 'static', + isServiceHostname: isObjectStorageOciHostname, + }) + ).toThrow('known registry') + }) +}) diff --git a/apps/sim/lib/internal/oci/endpoints.ts b/apps/sim/lib/internal/oci/endpoints.ts new file mode 100644 index 00000000000..25062fc8745 --- /dev/null +++ b/apps/sim/lib/internal/oci/endpoints.ts @@ -0,0 +1,254 @@ +import { isIpLiteral } from '@sim/security/ssrf' + +export type OciDestinationProvenance = 'static' | 'authenticated-discovery' + +export interface OciRealm { + readonly id: string + readonly domain: string +} + +export interface OciRegion { + readonly id: string + readonly realm: OciRealm +} + +declare const validatedOciDestinationBrand: unique symbol + +/** An OCI origin that passed both structural and service-owned hostname validation. */ +export interface ValidatedOciDestination { + readonly origin: string + readonly hostname: string + readonly service: string + readonly region: OciRegion + readonly provenance: OciDestinationProvenance + readonly [validatedOciDestinationBrand]: true +} + +declare const ociServiceHostnamePredicateBrand: unique symbol + +export type OciServiceHostnamePredicate = ((params: { + hostname: string + service: string + region: OciRegion + provenance: OciDestinationProvenance +}) => boolean) & { readonly [ociServiceHostnamePredicateBrand]: true } + +/** + * Realm and region snapshot copied from `oci-common@2.140.0` files + * `lib/realm.js` and `lib/region.js`, and verified byte-for-byte against the + * same registry files in `2.140.1`. Unknown runtime metadata is deliberately + * excluded so credentials cannot weaken endpoint trust with local OCI config. + */ +const REALM_DOMAINS = { + oc1: 'oraclecloud.com', + oc2: 'oraclegovcloud.com', + oc3: 'oraclegovcloud.com', + oc4: 'oraclegovcloud.uk', + oc8: 'oraclecloud8.com', + oc9: 'oraclecloud9.com', + oc10: 'oraclecloud10.com', + oc14: 'oraclecloud14.com', + oc15: 'oraclecloud15.com', + oc19: 'oraclecloud.eu', + oc20: 'oraclecloud20.com', + oc21: 'oraclecloud21.com', + oc23: 'oraclecloud23.com', + oc24: 'oraclecloud24.com', + oc26: 'oraclecloud26.com', + oc29: 'oraclecloud29.com', + oc35: 'oraclecloud35.com', + oc42: 'oraclecloud42.com', + oc51: 'oraclecloud51.com', + oc52: 'oraclecloud52.com', +} as const + +type OciRealmId = keyof typeof REALM_DOMAINS + +const REGION_REALMS = { + 'ap-chuncheon-1': 'oc1', + 'ap-mumbai-1': 'oc1', + 'ap-hyderabad-1': 'oc1', + 'ap-seoul-1': 'oc1', + 'ap-sydney-1': 'oc1', + 'ap-melbourne-1': 'oc1', + 'ap-osaka-1': 'oc1', + 'ap-tokyo-1': 'oc1', + 'ca-montreal-1': 'oc1', + 'ca-toronto-1': 'oc1', + 'eu-frankfurt-1': 'oc1', + 'eu-zurich-1': 'oc1', + 'sa-saopaulo-1': 'oc1', + 'uk-cardiff-1': 'oc1', + 'uk-london-1': 'oc1', + 'us-ashburn-1': 'oc1', + 'us-phoenix-1': 'oc1', + 'eu-amsterdam-1': 'oc1', + 'me-jeddah-1': 'oc1', + 'us-sanjose-1': 'oc1', + 'me-dubai-1': 'oc1', + 'sa-santiago-1': 'oc1', + 'sa-vinhedo-1': 'oc1', + 'il-jerusalem-1': 'oc1', + 'eu-marseille-1': 'oc1', + 'ap-singapore-1': 'oc1', + 'me-abudhabi-1': 'oc1', + 'eu-milan-1': 'oc1', + 'eu-stockholm-1': 'oc1', + 'af-johannesburg-1': 'oc1', + 'eu-paris-1': 'oc1', + 'mx-queretaro-1': 'oc1', + 'eu-madrid-1': 'oc1', + 'us-chicago-1': 'oc1', + 'mx-monterrey-1': 'oc1', + 'us-saltlake-2': 'oc1', + 'sa-bogota-1': 'oc1', + 'sa-valparaiso-1': 'oc1', + 'ap-singapore-2': 'oc1', + 'me-riyadh-1': 'oc1', + 'ap-delhi-1': 'oc1', + 'ap-batam-1': 'oc1', + 'eu-madrid-3': 'oc1', + 'eu-turin-1': 'oc1', + 'ap-kulai-2': 'oc1', + 'af-casablanca-1': 'oc1', + 'us-langley-1': 'oc2', + 'us-luke-1': 'oc2', + 'us-gov-ashburn-1': 'oc3', + 'us-gov-chicago-1': 'oc3', + 'us-gov-phoenix-1': 'oc3', + 'uk-gov-london-1': 'oc4', + 'uk-gov-cardiff-1': 'oc4', + 'ap-chiyoda-1': 'oc8', + 'ap-ibaraki-1': 'oc8', + 'me-dcc-muscat-1': 'oc9', + 'me-ibri-1': 'oc9', + 'ap-dcc-canberra-1': 'oc10', + 'eu-dcc-milan-1': 'oc14', + 'eu-dcc-milan-2': 'oc14', + 'eu-dcc-dublin-2': 'oc14', + 'eu-dcc-rating-2': 'oc14', + 'eu-dcc-rating-1': 'oc14', + 'eu-dcc-dublin-1': 'oc14', + 'ap-dcc-gazipur-1': 'oc15', + 'eu-madrid-2': 'oc19', + 'eu-frankfurt-2': 'oc19', + 'eu-jovanovac-1': 'oc20', + 'me-dcc-doha-1': 'oc21', + 'me-alrayyan-1': 'oc21', + 'us-somerset-1': 'oc23', + 'us-thames-1': 'oc23', + 'eu-dcc-zurich-1': 'oc24', + 'eu-crissier-1': 'oc24', + 'me-abudhabi-3': 'oc26', + 'me-alain-1': 'oc26', + 'me-abudhabi-2': 'oc29', + 'me-abudhabi-4': 'oc29', + 'ap-seoul-2': 'oc35', + 'ap-suwon-1': 'oc35', + 'ap-chuncheon-2': 'oc35', + 'us-ashburn-2': 'oc42', + 'us-newark-1': 'oc42', + 'eu-budapest-1': 'oc51', + 'sa-riodejaneiro-1': 'oc52', +} as const satisfies Record + +export const OCI_REGION_IDS = Object.freeze(Object.keys(REGION_REALMS)) + +function normalizeRegionId(regionId: string): string { + return regionId.trim().toLowerCase() +} + +export function getOciRegion(regionId: string): OciRegion { + const normalized = normalizeRegionId(regionId) + const realmId = REGION_REALMS[normalized as keyof typeof REGION_REALMS] + if (!realmId) throw new Error('OCI region is not recognized') + return { + id: normalized, + realm: { id: realmId, domain: REALM_DOMAINS[realmId] }, + } +} + +export function resolveEffectiveOciRegion(defaultRegion: string, override?: string): OciRegion { + const configured = getOciRegion(defaultRegion) + const effective = override === undefined ? configured : getOciRegion(override) + if (configured.realm.id !== effective.realm.id) { + throw new Error('OCI region override must remain in the credential realm') + } + return effective +} + +export function objectStorageOciHostname(region: OciRegion): string { + return `objectstorage.${region.id}.${region.realm.domain}` +} + +export function validateOciDestination(params: { + origin: string + service: string + region: OciRegion + provenance: OciDestinationProvenance + isServiceHostname: OciServiceHostnamePredicate +}): ValidatedOciDestination { + const knownRegion = getOciRegion(params.region.id) + if ( + knownRegion.realm.id !== params.region.realm.id || + knownRegion.realm.domain !== params.region.realm.domain + ) { + throw new Error('OCI destination region and realm must match the known registry') + } + let url: URL + try { + url = new URL(params.origin) + } catch { + throw new Error('OCI destination must be a valid HTTPS origin') + } + if ( + (params.provenance !== 'static' && params.provenance !== 'authenticated-discovery') || + !/^[a-z][a-z0-9-]{0,62}$/.test(params.service) || + url.protocol !== 'https:' || + url.port !== '' || + url.username !== '' || + url.password !== '' || + url.pathname !== '/' || + url.search !== '' || + url.hash !== '' || + isIpLiteral(url.hostname) || + url.origin !== params.origin + ) { + throw new Error('OCI destination must be an exact HTTPS origin with the default port') + } + if ( + !params.isServiceHostname({ + hostname: url.hostname, + service: params.service, + region: knownRegion, + provenance: params.provenance, + }) + ) { + throw new Error('OCI destination hostname is not owned by the requested service') + } + return { + origin: url.origin, + hostname: url.hostname, + service: params.service, + region: knownRegion, + provenance: params.provenance, + } as ValidatedOciDestination +} + +export const isObjectStorageOciHostname = (({ hostname, service, region }) => + service === 'objectstorage' && + hostname === objectStorageOciHostname(region)) as OciServiceHostnamePredicate + +export function objectStorageOciDestination( + region: OciRegion, + provenance: OciDestinationProvenance = 'static' +): ValidatedOciDestination { + const hostname = objectStorageOciHostname(region) + return validateOciDestination({ + origin: `https://${hostname}`, + service: 'objectstorage', + region, + provenance, + isServiceHostname: isObjectStorageOciHostname, + }) +} diff --git a/apps/sim/lib/internal/oci/errors.ts b/apps/sim/lib/internal/oci/errors.ts new file mode 100644 index 00000000000..8e5e21fc283 --- /dev/null +++ b/apps/sim/lib/internal/oci/errors.ts @@ -0,0 +1,59 @@ +const MAX_OCI_ERROR_FIELD_LENGTH = 1024 + +function sanitizeOciErrorField( + value: unknown, + sensitiveValues: readonly string[] = [] +): string | undefined { + if (typeof value !== 'string') return undefined + let sanitized = value + .replace(/-----BEGIN[\s\S]*/gi, '[redacted-key]') + .replace(/https?:\/\/[^\s"']+/gi, '[redacted-url]') + .replace(/Signature\s+version="1",[^\r\n]*/gi, '[redacted-authorization]') + for (const sensitiveValue of sensitiveValues) { + if (sensitiveValue.length > 0) sanitized = sanitized.split(sensitiveValue).join('[redacted]') + } + sanitized = sanitized.replace(/[\u0000-\u001f\u007f]/g, ' ').trim() + return sanitized ? sanitized.slice(0, MAX_OCI_ERROR_FIELD_LENGTH) : undefined +} + +/** A bounded, credential-safe projection of an OCI service error. */ +export class OciRequestError extends Error { + readonly status: number + readonly code?: string + readonly opcRequestId?: string + + constructor(params: { + status: number + code?: unknown + message?: unknown + opcRequestId?: unknown + sensitiveValues?: readonly string[] + }) { + const code = sanitizeOciErrorField(params.code, params.sensitiveValues) + const message = sanitizeOciErrorField(params.message, params.sensitiveValues) + super( + message ? `OCI request failed: ${message}` : `OCI request failed with status ${params.status}` + ) + this.name = 'OciRequestError' + this.status = params.status + this.code = code + this.opcRequestId = sanitizeOciErrorField(params.opcRequestId, params.sensitiveValues) + } +} + +export function parseOciErrorBody( + body: string, + sensitiveValues: readonly string[] = [] +): { code?: string; message?: string } { + try { + const parsed: unknown = JSON.parse(body) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {} + const record = parsed as Record + return { + code: sanitizeOciErrorField(record.code, sensitiveValues), + message: sanitizeOciErrorField(record.message, sensitiveValues), + } + } catch { + return {} + } +} diff --git a/apps/sim/lib/internal/oci/signing.server.test.ts b/apps/sim/lib/internal/oci/signing.server.test.ts new file mode 100644 index 00000000000..b8abd179775 --- /dev/null +++ b/apps/sim/lib/internal/oci/signing.server.test.ts @@ -0,0 +1,225 @@ +/** + * @vitest-environment node + */ +import { createHash, createPublicKey, createVerify, generateKeyPairSync } from 'node:crypto' +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { + type OciRequestMethod, + type OciSigningCredentials, + signOciRequest, +} from '@/lib/internal/oci/signing.server' + +/** Oracle's public request-signing fixture from the OCI Request Signatures documentation. */ +const ORACLE_FIXTURE_PRIVATE_KEY = `${['-----BEGIN', 'RSA PRIVATE KEY-----'].join(' ')} +MIICXgIBAAKBgQDCFENGw33yGihy92pDjZQhl0C36rPJj+CvfSC8+q28hxA161QF +NUd13wuCTUcq0Qd2qsBe/2hFyc2DCJJg0h1L78+6Z4UMR7EOcpfdUE9Hf3m/hs+F +UR45uBJeDK1HSFHD8bHKD6kv8FPGfJTotc+2xjJwoYi+1hqp1fIekaxsyQIDAQAB +AoGBAJR8ZkCUvx5kzv+utdl7T5MnordT1TvoXXJGXK7ZZ+UuvMNUCdN2QPc4sBiA +QWvLw1cSKt5DsKZ8UETpYPy8pPYnnDEz2dDYiaew9+xEpubyeW2oH4Zx71wqBtOK +kqwrXa/pzdpiucRRjk6vE6YY7EBBs/g7uanVpGibOVAEsqH1AkEA7DkjVH28WDUg +f1nqvfn2Kj6CT7nIcE3jGJsZZ7zlZmBmHFDONMLUrXR/Zm3pR5m0tCmBqa5RK95u +412jt1dPIwJBANJT3v8pnkth48bQo/fKel6uEYyboRtA5/uHuHkZ6FQF7OUkGogc +mSJluOdc5t6hI1VsLn0QZEjQZMEOWr+wKSMCQQCC4kXJEsHAve77oP6HtG/IiEn7 +kpyUXRNvFsDE0czpJJBvL/aRFUJxuRK91jhjC68sA7NsKMGg5OXb5I5Jj36xAkEA +gIT7aFOYBFwGgQAQkWNKLvySgKbAZRTeLBacpHMuQdl1DfdntvAyqpAZ0lY0RKmW +G6aFKaqQfOXKCyWoUiVknQJAXrlgySFci/2ueKlIE1QqIiLSZ8V8OlpFLRnb1pzI +7U1yQXnTAEFYM560yJlzUpOb1V4cScGd365tiSMvxLOvTA== +${['-----END', 'RSA PRIVATE KEY-----'].join(' ')}` + +const BASE_CREDENTIALS: OciSigningCredentials = { + tenancyId: 'ocid1.tenancy.oc1..oraclefixture', + userId: 'ocid1.user.oc1..oraclefixture', + fingerprint: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff', + privateKey: ORACLE_FIXTURE_PRIVATE_KEY, +} + +function authorizationParameter(authorization: string, name: string): string { + const match = new RegExp(`${name}="([^"]+)"`).exec(authorization) + if (!match?.[1]) throw new Error(`Missing ${name} authorization parameter`) + return match[1] +} + +function expectValidSignature(params: { + request: Awaited> + publicKey: ReturnType +}): void { + const authorization = params.request.headers.authorization + expect(authorization).toBeDefined() + const headerNames = authorizationParameter(authorization!, 'headers').split(' ') + const url = new URL(params.request.url) + const signingString = headerNames + .map((name) => { + if (name === '(request-target)') { + return `(request-target): ${params.request.method.toLowerCase()} ${url.pathname}${url.search}` + } + const value = params.request.headers[name.toLowerCase()] + if (value === undefined) throw new Error(`Signed header ${name} is absent`) + return `${name.toLowerCase()}: ${value}` + }) + .join('\n') + const signature = authorizationParameter(authorization!, 'signature') + const verifier = createVerify('RSA-SHA256').update(signingString).end() + expect(verifier.verify(params.publicKey, signature, 'base64')).toBe(true) +} + +describe('signOciRequest', () => { + let generatedCredentials: OciSigningCredentials + let encryptedCredentials: OciSigningCredentials + let generatedPublicKey: ReturnType + + beforeAll(() => { + const pair = generateKeyPairSync('rsa', { modulusLength: 2048 }) + const privateKey = pair.privateKey.export({ format: 'pem', type: 'pkcs8' }).toString() + generatedPublicKey = createPublicKey(pair.privateKey) + generatedCredentials = { ...BASE_CREDENTIALS, privateKey } + encryptedCredentials = { + ...BASE_CREDENTIALS, + privateKey: pair.privateKey + .export({ + format: 'pem', + type: 'pkcs8', + cipher: 'aes-256-cbc', + passphrase: 'signing-test-passphrase', + }) + .toString(), + passphrase: 'signing-test-passphrase', + } + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('signs Oracle’s published RSA fixture entirely in memory', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-09-03T19:00:00.000Z')) + const request = await signOciRequest({ + credentials: BASE_CREDENTIALS, + method: 'GET', + url: 'https://iaas.us-phoenix-1.oraclecloud.com/20160918/instances?displayName=Team%20X', + }) + expectValidSignature({ request, publicKey: createPublicKey(ORACLE_FIXTURE_PRIVATE_KEY) }) + expect(request.headers.authorization).toContain( + `keyId="${BASE_CREDENTIALS.tenancyId}/${BASE_CREDENTIALS.userId}/${BASE_CREDENTIALS.fingerprint}"` + ) + }) + + it('signs with an independently generated encrypted PKCS#8 key', async () => { + const request = await signOciRequest({ + credentials: encryptedCredentials, + method: 'GET', + url: 'https://identity.us-ashburn-1.oraclecloud.com/20160918/users', + }) + expectValidSignature({ request, publicKey: generatedPublicKey }) + }) + + it.each(['GET', 'HEAD', 'DELETE'] as const)('signs %s without body headers', async (method) => { + const request = await signOciRequest({ + credentials: generatedCredentials, + method, + url: 'https://identity.us-ashburn-1.oraclecloud.com/20160918/users?a=1&a=&name=%E2%98%83', + serviceHeaders: { accept: 'application/json' }, + }) + expect(request.body).toBeUndefined() + expect(request.headers['content-length']).toBeUndefined() + expect(request.headers['x-content-sha256']).toBeUndefined() + expect(request.headers.date).toBeUndefined() + expectValidSignature({ request, publicKey: generatedPublicKey }) + }) + + it.each(['POST', 'PUT', 'PATCH'] as const)( + 'signs empty and Unicode %s bodies with byte-correct headers', + async (method) => { + for (const body of ['', '{"message":"héllo ☃"}']) { + const request = await signOciRequest({ + credentials: generatedCredentials, + method, + url: 'https://identity.us-ashburn-1.oraclecloud.com/20160918/users', + body, + }) + expect(request.body).toBe(body) + expect(request.headers['content-length']).toBe(String(Buffer.byteLength(body, 'utf8'))) + expect(request.headers['x-content-sha256']).toBe( + createHash('sha256').update(body, 'utf8').digest('base64') + ) + expect(request.headers['content-type']).toBe('application/json') + expect(request.headers.date).toBeUndefined() + expectValidSignature({ request, publicKey: generatedPublicKey }) + } + } + ) + + it('preserves finalized URL/query bytes in the signed request target', async () => { + const url = + 'https://identity.us-ashburn-1.oraclecloud.com/resource?z=last&a=one&a=&unicode=%E2%98%83' + const request = await signOciRequest({ credentials: generatedCredentials, method: 'GET', url }) + expect(request.url).toBe(url) + expectValidSignature({ request, publicKey: generatedPublicKey }) + }) + + it('creates a fresh x-date and removes the signer’s unsigned date header', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-09-03T19:00:00.000Z')) + const first = await signOciRequest({ + credentials: generatedCredentials, + method: 'GET', + url: 'https://identity.us-ashburn-1.oraclecloud.com/a', + }) + vi.setSystemTime(new Date('2026-09-03T19:00:01.000Z')) + const second = await signOciRequest({ + credentials: generatedCredentials, + method: 'GET', + url: 'https://identity.us-ashburn-1.oraclecloud.com/a', + }) + expect(first.headers['x-date']).not.toBe(second.headers['x-date']) + expect(first.headers.date).toBeUndefined() + expect(second.headers.date).toBeUndefined() + }) + + it.each(['GET', 'HEAD', 'DELETE'] as OciRequestMethod[])( + 'rejects a body on %s', + async (method) => { + await expect( + signOciRequest({ + credentials: generatedCredentials, + method, + url: 'https://identity.us-ashburn-1.oraclecloud.com/a', + body: '', + }) + ).rejects.toThrow('must not include a body') + } + ) + + it.each([Buffer.from('body'), new Uint8Array([1, 2, 3])])( + 'rejects non-string request bodies', + async (body) => { + await expect( + signOciRequest({ + credentials: generatedCredentials, + method: 'POST', + url: 'https://identity.us-ashburn-1.oraclecloud.com/a', + body: body as unknown as string, + }) + ).rejects.toThrow('finalized strings') + } + ) + + it.each([ + 'Authorization', + 'HOST', + 'date', + 'x-date', + 'content-length', + 'content-type', + 'x-content-sha256', + ])('blocks callers from overriding %s', async (header) => { + await expect( + signOciRequest({ + credentials: generatedCredentials, + method: 'GET', + url: 'https://identity.us-ashburn-1.oraclecloud.com/a', + serviceHeaders: { [header]: 'attacker-controlled' }, + }) + ).rejects.toThrow('signing-controlled') + }) +}) diff --git a/apps/sim/lib/internal/oci/signing.server.ts b/apps/sim/lib/internal/oci/signing.server.ts new file mode 100644 index 00000000000..7382c87ed36 --- /dev/null +++ b/apps/sim/lib/internal/oci/signing.server.ts @@ -0,0 +1,104 @@ +import { DefaultRequestSigner, SimpleAuthenticationDetailsProvider } from 'oci-common' + +export type OciRequestMethod = 'GET' | 'HEAD' | 'DELETE' | 'POST' | 'PUT' | 'PATCH' + +export interface OciSigningCredentials { + readonly tenancyId: string + readonly userId: string + readonly fingerprint: string + readonly privateKey: string + readonly passphrase?: string +} + +export interface SignedOciRequest { + readonly method: OciRequestMethod + readonly url: string + readonly headers: Readonly> + readonly body?: string +} + +const BODY_METHODS: ReadonlySet = new Set(['POST', 'PUT', 'PATCH']) + +export const OCI_SIGNING_CONTROLLED_HEADERS: ReadonlySet = new Set([ + 'authorization', + 'host', + 'date', + 'x-date', + 'content-length', + 'content-type', + 'x-content-sha256', +]) + +function assertServiceHeaders(headers: Readonly>): void { + for (const [name, value] of Object.entries(headers)) { + if (OCI_SIGNING_CONTROLLED_HEADERS.has(name.toLowerCase())) { + throw new Error(`OCI service header is signing-controlled: ${name}`) + } + if ( + typeof value !== 'string' || + !/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(name) || + /[\u0000-\u001f\u007f]/.test(value) + ) { + throw new Error('OCI service headers must not contain control characters') + } + } +} + +/** Signs one finalized OCI request without consulting local OCI configuration. */ +export async function signOciRequest(params: { + credentials: OciSigningCredentials + method: OciRequestMethod + url: string + serviceHeaders?: Readonly> + body?: string + contentType?: string +}): Promise { + const serviceHeaders = params.serviceHeaders ?? {} + assertServiceHeaders(serviceHeaders) + const hasBodyMethod = BODY_METHODS.has(params.method) + if (!hasBodyMethod && params.body !== undefined) { + throw new Error(`${params.method} requests must not include a body`) + } + if (params.body !== undefined && typeof params.body !== 'string') { + throw new Error('OCI request bodies must be finalized strings') + } + if (params.contentType !== undefined && !hasBodyMethod) { + throw new Error('OCI content type is only valid for requests with signed bodies') + } + if ( + params.contentType !== undefined && + (params.contentType.length === 0 || + params.contentType.length > 256 || + /[\u0000-\u001f\u007f]/.test(params.contentType)) + ) { + throw new Error('OCI content type must not contain control characters') + } + + const body = hasBodyMethod ? (params.body ?? '') : undefined + const headers = new Headers(serviceHeaders) + headers.set('x-date', new Date().toUTCString()) + if (hasBodyMethod) headers.set('content-type', params.contentType ?? 'application/json') + + const provider = new SimpleAuthenticationDetailsProvider( + params.credentials.tenancyId, + params.credentials.userId, + params.credentials.fingerprint, + params.credentials.privateKey, + params.credentials.passphrase ?? null + ) + const signer = new DefaultRequestSigner(provider) + await signer.signHttpRequest({ + method: params.method, + uri: params.url, + headers, + ...(body !== undefined ? { body } : {}), + }) + headers.delete('date') + + return { + method: params.method, + url: params.url, + headers: Object.fromEntries(headers.entries()), + ...(body !== undefined ? { body } : {}), + } +} diff --git a/apps/sim/lib/oauth/types.ts b/apps/sim/lib/oauth/types.ts index 3e1efe990ea..ce8e727a2de 100644 --- a/apps/sim/lib/oauth/types.ts +++ b/apps/sim/lib/oauth/types.ts @@ -14,6 +14,12 @@ export const ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID = 'atlassian-service-account' */ export const GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID = 'google-service-account' as const +/** Stable identifier for an OCI API-key user-principal credential. */ +export const OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID = 'oci-api-key-service-account' as const + +/** Discriminator stored inside the encrypted OCI API signing-key secret blob. */ +export const OCI_API_KEY_SERVICE_ACCOUNT_SECRET_TYPE = 'oci_api_signing_key_v1' as const + /** * Discriminator stored inside the encrypted Atlassian service account secret blob. */ diff --git a/apps/sim/package.json b/apps/sim/package.json index 89753ba2f90..2355ccaf47a 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -210,6 +210,7 @@ "next-themes": "^0.4.6", "nodemailer": "9.0.1", "nuqs": "2.8.9", + "oci-common": "2.140.0", "officeparser": "5.2.2", "openai": "7.0.0", "opentype.js": "1.3.4", diff --git a/bun.lock b/bun.lock index 134649eafe6..c455c3b02e3 100644 --- a/bun.lock +++ b/bun.lock @@ -320,6 +320,7 @@ "next-themes": "^0.4.6", "nodemailer": "9.0.1", "nuqs": "2.8.9", + "oci-common": "2.140.0", "officeparser": "5.2.2", "openai": "7.0.0", "opentype.js": "1.3.4", @@ -2227,12 +2228,16 @@ "@types/http-cache-semantics": ["@types/http-cache-semantics@4.2.0", "", {}, "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q=="], + "@types/isomorphic-fetch": ["@types/isomorphic-fetch@0.0.35", "", {}, "sha512-DaZNUvLDCAnCTjgwxgiL1eQdxIKEpNLOlTNtAgnZc50bG2copGhRrFN9/PxPBuJe+tZVLCbQ7ls0xveXVRPkvw=="], + "@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="], "@types/jsdom": ["@types/jsdom@21.1.7", "", { "dependencies": { "@types/node": "*", "@types/tough-cookie": "*", "parse5": "^7.0.0" } }, "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA=="], "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + "@types/jsonwebtoken": ["@types/jsonwebtoken@9.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-b0jGiOgHtZ2jqdPgPnP6WLCXZk1T8p06A/vPGzUvxpFGgKMbjXJDjC5m52ErqBnIuWZFgGoIJyRdeG5AyreJjA=="], + "@types/keyv": ["@types/keyv@3.1.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg=="], "@types/lodash": ["@types/lodash@4.17.24", "", {}, "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ=="], @@ -2255,6 +2260,8 @@ "@types/opentype.js": ["@types/opentype.js@1.3.10", "", {}, "sha512-F67EFyk6j02okHz5JCgata3ZRAcZi9GLnzmkHw/rzJq3OCc8/ZVdoKrxMTYjcQP6IYHGBz2cav1cpzkOkPiPCQ=="], + "@types/opossum": ["@types/opossum@4.1.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-9TMnd8AWRVtnZMqBbbzceQoJdafErgUViogFaQ3eetsbeLtiFFZ695mepNaLtlfJi4uRP3GmHfe3CJ2DZKaxYA=="], + "@types/pako": ["@types/pako@1.0.7", "", {}, "sha512-YBtzT2ztNF6R/9+UXj2wTGFnC9NklAnASt3sC0h2m1bbH7G6FyBIkt4AN8ThZpNfxUo1b2iMVO0UawiJymEt8A=="], "@types/prismjs": ["@types/prismjs@1.26.6", "", {}, "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw=="], @@ -2275,6 +2282,8 @@ "@types/ssh2": ["@types/ssh2@1.15.5", "", { "dependencies": { "@types/node": "^18.11.18" } }, "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ=="], + "@types/sshpk": ["@types/sshpk@1.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-cru1waDhHZnZuB18E6Dgf2UXf8U93mdOEDcKYe5jTri+fpucidSs7DLmGICpLxN+95aYkwtgeyny9fBFzQVdmA=="], + "@types/tough-cookie": ["@types/tough-cookie@4.0.5", "", {}, "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA=="], "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], @@ -2283,6 +2292,8 @@ "@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="], + "@types/uuid": ["@types/uuid@8.3.4", "", {}, "sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw=="], + "@types/webidl-conversions": ["@types/webidl-conversions@7.0.3", "", {}, "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA=="], "@types/whatwg-url": ["@types/whatwg-url@11.0.5", "", { "dependencies": { "@types/webidl-conversions": "*" } }, "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ=="], @@ -2445,6 +2456,8 @@ "asn1js": ["asn1js@3.0.10", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.5", "tslib": "^2.8.1" } }, "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg=="], + "assert-plus": ["assert-plus@1.0.0", "", {}, "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw=="], + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], "ast-v8-to-istanbul": ["ast-v8-to-istanbul@1.0.4", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA=="], @@ -2805,6 +2818,8 @@ "dagre-d3-es": ["dagre-d3-es@7.0.14", "", { "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" } }, "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg=="], + "dashdash": ["dashdash@1.14.1", "", { "dependencies": { "assert-plus": "^1.0.0" } }, "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g=="], + "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], "data-urls": ["data-urls@5.0.0", "", { "dependencies": { "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.0.0" } }, "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg=="], @@ -2929,6 +2944,8 @@ "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], + "ecc-jsbn": ["ecc-jsbn@0.1.2", "", { "dependencies": { "jsbn": "~0.1.0", "safer-buffer": "^2.1.0" } }, "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw=="], + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], "echarts": ["echarts@6.1.0", "", { "dependencies": { "tslib": "2.3.0", "zrender": "6.1.0" } }, "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA=="], @@ -2995,6 +3012,8 @@ "es6-error": ["es6-error@4.1.1", "", {}, "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg=="], + "es6-promise": ["es6-promise@4.2.6", "", {}, "sha512-aRVgGdnmW2OiySVPUC9e6m+plolMAJKjZnQlCwNSuK5yQ0JN61DZSO1X1Ufd1foqWRAlig0rhduTCHe7sVtK5Q=="], + "esast-util-from-estree": ["esast-util-from-estree@2.0.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "devlop": "^1.0.0", "estree-util-visit": "^2.0.0", "unist-util-position-from-estree": "^2.0.0" } }, "sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ=="], "esast-util-from-js": ["esast-util-from-js@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "acorn": "^8.0.0", "esast-util-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw=="], @@ -3063,6 +3082,8 @@ "extend-shallow": ["extend-shallow@2.0.1", "", { "dependencies": { "is-extendable": "^0.1.0" } }, "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug=="], + "extsprintf": ["extsprintf@1.3.0", "", {}, "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g=="], + "fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="], "fast-content-type-parse": ["fast-content-type-parse@2.0.1", "", {}, "sha512-nGqtvLrj5w0naR6tDPfB4cUmYCqouzyQiz6C5y/LtcDllJdrcc6WaWW6iXyIIOErTa/XRybj28aasdn4LkVk6Q=="], @@ -3177,6 +3198,8 @@ "get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="], + "getpass": ["getpass@0.1.7", "", { "dependencies": { "assert-plus": "^1.0.0" } }, "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng=="], + "giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="], "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], @@ -3289,6 +3312,8 @@ "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + "http-signature": ["http-signature@1.3.1", "", { "dependencies": { "assert-plus": "^1.0.0", "jsprim": "^1.2.2", "sshpk": "^1.14.1" } }, "sha512-Y29YKEc8MQsjch/VzkUVJ+2MXd9WcR42fK5u36CZf4G8bXw2DXMTWuESiB0R6m59JAWxlPPw5/Fri/t/AyyueA=="], + "http2-wrapper": ["http2-wrapper@2.2.1", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.2.0" } }, "sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ=="], "https": ["https@1.0.0", "", {}, "sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg=="], @@ -3389,6 +3414,8 @@ "isolated-vm": ["isolated-vm@6.2.0", "", { "dependencies": { "node-gyp-build": "^4.8.4" } }, "sha512-UuSlxSHWt2QuJ5WvBhzlIJx2VVZN/a44SqBbEZFKNdvuSyhOvhmyDo8SQ+njVbhnh/njoL/aW0bUTiFYlpweGQ=="], + "isomorphic-fetch": ["isomorphic-fetch@3.0.0", "", { "dependencies": { "node-fetch": "^2.6.1", "whatwg-fetch": "^3.4.1" } }, "sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA=="], + "isomorphic-ws": ["isomorphic-ws@5.0.0", "", { "peerDependencies": { "ws": "*" } }, "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw=="], "isomorphic.js": ["isomorphic.js@0.2.5", "", {}, "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw=="], @@ -3423,6 +3450,8 @@ "js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="], + "jsbn": ["jsbn@0.1.1", "", {}, "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg=="], + "jsdom": ["jsdom@26.1.0", "", { "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", "decimal.js": "^10.5.0", "html-encoding-sniffer": "^4.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "nwsapi": "^2.2.16", "parse5": "^7.2.1", "rrweb-cssom": "^0.8.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^5.1.1", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^7.0.0", "whatwg-encoding": "^3.1.1", "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.1.1", "ws": "^8.18.0", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg=="], "jsep": ["jsep@1.4.0", "", {}, "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw=="], @@ -3453,6 +3482,10 @@ "jsonwebtoken": ["jsonwebtoken@9.0.3", "", { "dependencies": { "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g=="], + "jsprim": ["jsprim@1.4.2", "", { "dependencies": { "assert-plus": "1.0.0", "extsprintf": "1.3.0", "json-schema": "0.4.0", "verror": "1.10.0" } }, "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw=="], + + "jssha": ["jssha@3.3.1", "", {}, "sha512-VCMZj12FCFMQYcFLPRm/0lOBbLi8uM2BhXPTqw3U4YAfs4AZfiApOoBLoN8cQE60Z50m1MYMTQVCfgF/KaCVhQ=="], + "jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="], "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], @@ -3853,6 +3886,8 @@ "obug": ["obug@2.1.3", "", {}, "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg=="], + "oci-common": ["oci-common@2.140.0", "", { "dependencies": { "@types/isomorphic-fetch": "0.0.35", "@types/jsonwebtoken": "9.0.3", "@types/opossum": "4.1.1", "@types/sshpk": "1.10.3", "@types/uuid": "8.3.4", "es6-promise": "4.2.6", "http-signature": "1.3.1", "isomorphic-fetch": "3.0.0", "jsonwebtoken": "9.0.3", "jssha": "3.3.1", "opossum": "5.0.1", "sshpk": "1.18.0", "uuid": "11.1.1" } }, "sha512-yHdfmB0gIx0QYC7sNvgll4HEw+w+fVo/eldhZfN2VxLJK+oqVHDlr6rT/ytwK8Fc8bS5XPkofIpCStPNR10MdQ=="], + "officeparser": ["officeparser@5.2.2", "", { "dependencies": { "@xmldom/xmldom": "^0.8.10", "concat-stream": "^2.0.0", "file-type": "^16.5.4", "node-ensure": "^0.0.0", "pdfjs-dist": "^5.3.31", "yauzl": "^3.1.3" }, "bin": { "officeparser": "officeParser.js" } }, "sha512-5JrV1CZFqTv/27fXy2bcf+3g6BpDZiJ3XoSRW3fb2i2EFex0DduqjTxiU2RsJ08WBsk4Hp0nZoGi9ZtHMZFaPA=="], "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], @@ -3883,6 +3918,8 @@ "opentype.js": ["opentype.js@1.3.4", "", { "dependencies": { "string.prototype.codepointat": "^0.2.1", "tiny-inflate": "^1.0.3" }, "bin": { "ot": "bin/ot" } }, "sha512-d2JE9RP/6uagpQAVtJoF0pJJA/fgai89Cc50Yp0EJHk+eLp6QQ7gBoblsnubRULNY132I0J1QKMJ+JTbMqz4sw=="], + "opossum": ["opossum@5.0.1", "", {}, "sha512-iUDUQmFl3RanaBVLMDTZ6WtXj/Hk84pwJ5JWoJaQd1lXGifdApHhszI3biZvdBDdpTERCmB6x+7+uNvzhzVZIg=="], + "option": ["option@0.2.4", "", {}, "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A=="], "ora": ["ora@4.1.1", "", { "dependencies": { "chalk": "^3.0.0", "cli-cursor": "^3.1.0", "cli-spinners": "^2.2.0", "is-interactive": "^1.0.0", "log-symbols": "^3.0.0", "mute-stream": "0.0.8", "strip-ansi": "^6.0.0", "wcwidth": "^1.0.1" } }, "sha512-sjYP8QyVWBpBZWD6Vr1M/KwknSw6kJOz41tvGMlwWeClHBtYKTbHMki1PsLZnxKpXMPbTKv9b3pjQu3REib96A=="], @@ -4351,6 +4388,8 @@ "ssh2": ["ssh2@1.17.0", "", { "dependencies": { "asn1": "^0.2.6", "bcrypt-pbkdf": "^1.0.2" }, "optionalDependencies": { "cpu-features": "~0.0.10", "nan": "^2.23.0" } }, "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ=="], + "sshpk": ["sshpk@1.18.0", "", { "dependencies": { "asn1": "~0.2.3", "assert-plus": "^1.0.0", "bcrypt-pbkdf": "^1.0.0", "dashdash": "^1.12.0", "ecc-jsbn": "~0.1.1", "getpass": "^0.1.1", "jsbn": "~0.1.0", "safer-buffer": "^2.0.2", "tweetnacl": "~0.14.0" }, "bin": { "sshpk-conv": "bin/sshpk-conv", "sshpk-sign": "bin/sshpk-sign", "sshpk-verify": "bin/sshpk-verify" } }, "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ=="], + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], "standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="], @@ -4603,6 +4642,8 @@ "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + "verror": ["verror@1.10.0", "", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw=="], + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], "vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="], @@ -4643,6 +4684,8 @@ "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], + "whatwg-fetch": ["whatwg-fetch@3.6.20", "", {}, "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg=="], + "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], "whatwg-url": ["whatwg-url@14.2.0", "", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="], @@ -5027,6 +5070,8 @@ "@types/fs-extra/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], + "@types/jsonwebtoken/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], + "@types/keyv/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], "@types/mssql/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], @@ -5035,6 +5080,8 @@ "@types/nodemailer/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], + "@types/opossum/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], + "@types/readable-stream/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], "@types/readdir-glob/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], @@ -5047,6 +5094,8 @@ "@types/ssh2/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], + "@types/sshpk/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], + "@types/ws/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], "@vitest/expect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], @@ -5441,6 +5490,8 @@ "unzipper/fs-extra": ["fs-extra@11.3.1", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g=="], + "verror/core-util-is": ["core-util-is@1.0.2", "", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="], + "whatwg-encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], "widest-line/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -5653,6 +5704,8 @@ "@types/fs-extra/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + "@types/jsonwebtoken/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + "@types/keyv/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], "@types/mssql/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], @@ -5661,6 +5714,8 @@ "@types/nodemailer/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + "@types/opossum/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + "@types/readable-stream/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], "@types/readdir-glob/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], @@ -5673,6 +5728,8 @@ "@types/ssh2/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], + "@types/sshpk/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + "@types/ws/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], "accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], From 4ee814c4f0d0b3aadcf0346ab4fbcb2f6e948c1a Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos <157128530+BillLeoutsakosvl346@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:40:36 -0700 Subject: [PATCH 02/29] fix(oci): harden endpoint and error validation --- .../lib/internal/oci/client.server.test.ts | 28 +++++++++++ apps/sim/lib/internal/oci/endpoints.test.ts | 15 ++++++ apps/sim/lib/internal/oci/endpoints.ts | 8 +-- apps/sim/lib/internal/oci/errors.ts | 49 ++++++++++++++++++- 4 files changed, 96 insertions(+), 4 deletions(-) diff --git a/apps/sim/lib/internal/oci/client.server.test.ts b/apps/sim/lib/internal/oci/client.server.test.ts index 4190166cd0d..1d3fae3cb4d 100644 --- a/apps/sim/lib/internal/oci/client.server.test.ts +++ b/apps/sim/lib/internal/oci/client.server.test.ts @@ -210,6 +210,34 @@ describe('OCI request client', () => { expect((failure as OciRequestError).opcRequestId).toBe('request-502') }) + it('redacts authorization material embedded in a serialized JSON message', async () => { + const echoedAuthorization = + 'Signature version="1",keyId="tenant/user/fingerprint",headers="(request-target) host x-date",signature="provider-echo"' + secureFetchMock.mockResolvedValueOnce( + secureResponse({ + ok: false, + status: 401, + body: JSON.stringify({ + code: 'NotAuthenticated', + message: JSON.stringify({ authorization: echoedAuthorization }), + }), + }) + ) + const failure = await sendOciRequest({ + destination, + credentials, + method: 'GET', + encodedPath: '/n/', + timeout: 10_000, + maxResponseBytes: 65_536, + }).catch((error: unknown) => error) + expect(failure).toBeInstanceOf(OciRequestError) + expect((failure as Error).message).toContain('[redacted]') + expect((failure as Error).message).not.toContain('provider-echo') + expect((failure as Error).message).not.toContain('(request-target)') + expect((failure as Error).message).not.toContain('tenant/user/fingerprint') + }) + it.each([ '//attacker.example/path', '/safe//attacker', diff --git a/apps/sim/lib/internal/oci/endpoints.test.ts b/apps/sim/lib/internal/oci/endpoints.test.ts index cd789b00f25..628ddb1adb9 100644 --- a/apps/sim/lib/internal/oci/endpoints.test.ts +++ b/apps/sim/lib/internal/oci/endpoints.test.ts @@ -6,6 +6,7 @@ import { getOciRegion, isObjectStorageOciHostname, OCI_REGION_IDS, + type OciServiceHostnamePredicate, objectStorageOciDestination, objectStorageOciHostname, resolveEffectiveOciRegion, @@ -27,6 +28,7 @@ describe('OCI region registry', () => { it('normalizes known regions and fails closed for unknown regions', () => { expect(getOciRegion(' US-ASHBURN-1 ').id).toBe('us-ashburn-1') expect(() => getOciRegion('moon-base-1')).toThrow('not recognized') + expect(() => getOciRegion('constructor')).toThrow('not recognized') }) it('allows only same-realm effective-region overrides', () => { @@ -105,6 +107,19 @@ describe('validateOciDestination', () => { ).toThrow('not owned') }) + it('rejects a bracketed IPv6 literal before applying the service predicate', () => { + const acceptsEveryHostname = (() => true) as OciServiceHostnamePredicate + expect(() => + validateOciDestination({ + origin: 'https://[2606:4700::1111]', + service: 'objectstorage', + region, + provenance: 'static', + isServiceHostname: acceptsEveryHostname, + }) + ).toThrow('exact HTTPS origin') + }) + it('rejects a forged region-to-realm association', () => { expect(() => validateOciDestination({ diff --git a/apps/sim/lib/internal/oci/endpoints.ts b/apps/sim/lib/internal/oci/endpoints.ts index 25062fc8745..2e576890113 100644 --- a/apps/sim/lib/internal/oci/endpoints.ts +++ b/apps/sim/lib/internal/oci/endpoints.ts @@ -1,4 +1,4 @@ -import { isIpLiteral } from '@sim/security/ssrf' +import { isIpLiteral, unwrapIpv6Brackets } from '@sim/security/ssrf' export type OciDestinationProvenance = 'static' | 'authenticated-discovery' @@ -160,7 +160,9 @@ function normalizeRegionId(regionId: string): string { export function getOciRegion(regionId: string): OciRegion { const normalized = normalizeRegionId(regionId) - const realmId = REGION_REALMS[normalized as keyof typeof REGION_REALMS] + const realmId = Object.hasOwn(REGION_REALMS, normalized) + ? REGION_REALMS[normalized as keyof typeof REGION_REALMS] + : undefined if (!realmId) throw new Error('OCI region is not recognized') return { id: normalized, @@ -211,7 +213,7 @@ export function validateOciDestination(params: { url.pathname !== '/' || url.search !== '' || url.hash !== '' || - isIpLiteral(url.hostname) || + isIpLiteral(unwrapIpv6Brackets(url.hostname)) || url.origin !== params.origin ) { throw new Error('OCI destination must be an exact HTTPS origin with the default port') diff --git a/apps/sim/lib/internal/oci/errors.ts b/apps/sim/lib/internal/oci/errors.ts index 8e5e21fc283..a74cd527cf2 100644 --- a/apps/sim/lib/internal/oci/errors.ts +++ b/apps/sim/lib/internal/oci/errors.ts @@ -1,11 +1,58 @@ const MAX_OCI_ERROR_FIELD_LENGTH = 1024 +const MAX_OCI_ERROR_INPUT_LENGTH = 8192 +const MAX_NESTED_JSON_DEPTH = 3 +const SENSITIVE_JSON_FIELDS = new Set([ + 'authorization', + 'passphrase', + 'privatekey', + 'proxyauthorization', + 'signingstring', +]) + +function flattenJsonDiagnostic(value: unknown, depth = 0): string | undefined { + if (depth > MAX_NESTED_JSON_DEPTH || value === null) return undefined + if (typeof value === 'string') return value + if (typeof value === 'number' || typeof value === 'boolean') return String(value) + if (Array.isArray(value)) { + return value + .map((entry) => flattenJsonDiagnostic(entry, depth + 1)) + .filter((entry): entry is string => entry !== undefined) + .join(' ') + } + if (typeof value !== 'object') return undefined + return Object.entries(value) + .map(([key, entry]) => { + const normalizedKey = key.replace(/[^a-z]/gi, '').toLowerCase() + if (SENSITIVE_JSON_FIELDS.has(normalizedKey)) return `${key}: [redacted]` + const flattened = flattenJsonDiagnostic(entry, depth + 1) + return flattened === undefined ? undefined : `${key}: ${flattened}` + }) + .filter((entry): entry is string => entry !== undefined) + .join(' ') +} + +function decodeNestedJsonDiagnostic(value: string): string { + let decoded = value.slice(0, MAX_OCI_ERROR_INPUT_LENGTH) + for (let depth = 0; depth < MAX_NESTED_JSON_DEPTH; depth += 1) { + let parsed: unknown + try { + parsed = JSON.parse(decoded) + } catch { + break + } + const flattened = flattenJsonDiagnostic(parsed) + if (flattened === undefined || flattened === decoded) break + decoded = flattened.slice(0, MAX_OCI_ERROR_INPUT_LENGTH) + } + return decoded +} function sanitizeOciErrorField( value: unknown, sensitiveValues: readonly string[] = [] ): string | undefined { if (typeof value !== 'string') return undefined - let sanitized = value + let sanitized = decodeNestedJsonDiagnostic(value) .replace(/-----BEGIN[\s\S]*/gi, '[redacted-key]') .replace(/https?:\/\/[^\s"']+/gi, '[redacted-url]') .replace(/Signature\s+version="1",[^\r\n]*/gi, '[redacted-authorization]') From b322a131aac7295388cb1b8f9b94d94f814fe849 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos <157128530+BillLeoutsakosvl346@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:09:48 -0700 Subject: [PATCH 03/29] fix(oci): fail closed on encoded diagnostics --- .../lib/internal/oci/client.server.test.ts | 141 +++++++++++++++++- apps/sim/lib/internal/oci/client.server.ts | 10 +- apps/sim/lib/internal/oci/errors.ts | 137 +++++++++++------ 3 files changed, 242 insertions(+), 46 deletions(-) diff --git a/apps/sim/lib/internal/oci/client.server.test.ts b/apps/sim/lib/internal/oci/client.server.test.ts index 1d3fae3cb4d..77553358300 100644 --- a/apps/sim/lib/internal/oci/client.server.test.ts +++ b/apps/sim/lib/internal/oci/client.server.test.ts @@ -181,7 +181,7 @@ describe('OCI request client', () => { code: 'NotAuthenticated', opcRequestId: 'request-401', }) - expect((failure as Error).message).toContain('[redacted]') + expect((failure as Error).message).toContain('[REDACTED]') expect((failure as Error).message).not.toContain('client-secret-passphrase') expect((failure as Error).message).not.toContain('BEGIN PRIVATE KEY') expect((failure as Error).message).not.toContain('objectstorage.us-ashburn-1') @@ -232,12 +232,149 @@ describe('OCI request client', () => { maxResponseBytes: 65_536, }).catch((error: unknown) => error) expect(failure).toBeInstanceOf(OciRequestError) - expect((failure as Error).message).toContain('[redacted]') + expect((failure as Error).message).toContain('[REDACTED]') expect((failure as Error).message).not.toContain('provider-echo') expect((failure as Error).message).not.toContain('(request-target)') expect((failure as Error).message).not.toContain('tenant/user/fingerprint') }) + it('redacts encoded credentials and request URLs echoed by the provider', async () => { + const encodedFingerprint = encodeURIComponent(credentials.fingerprint) + const requestUrl = `${destination.origin}/n/` + const encodedRequestUrl = encodeURIComponent(requestUrl) + const escapedPassphrase = 'secret "pass"' + const escapedPassphraseEcho = JSON.stringify(escapedPassphrase).slice(1, -1) + secureFetchMock.mockResolvedValueOnce( + secureResponse({ + ok: false, + status: 401, + body: JSON.stringify({ + code: 'NotAuthenticated', + message: `provider echoed ${encodedFingerprint} ${encodedRequestUrl} ${escapedPassphraseEcho}`, + }), + }) + ) + const failure = await sendOciRequest({ + destination, + credentials: { ...credentials, passphrase: escapedPassphrase }, + method: 'GET', + encodedPath: '/n/', + timeout: 10_000, + maxResponseBytes: 65_536, + }).catch((error: unknown) => error) + expect((failure as Error).message).not.toContain(encodedFingerprint) + expect((failure as Error).message).not.toContain(encodedRequestUrl) + expect((failure as Error).message).not.toContain(escapedPassphraseEcho) + expect((failure as Error).message).not.toContain(escapedPassphrase) + }) + + it('redacts a maximum-size passphrase before bounding an encoded diagnostic', async () => { + const longPassphrase = ' '.repeat(4096) + const encodedPassphrase = new URLSearchParams({ value: longPassphrase }) + .toString() + .slice('value='.length) + secureFetchMock.mockResolvedValueOnce( + secureResponse({ + ok: false, + status: 401, + body: JSON.stringify({ + code: 'NotAuthenticated', + message: `provider echoed ${encodedPassphrase}`, + }), + }) + ) + const failure = await sendOciRequest({ + destination, + credentials: { ...credentials, passphrase: longPassphrase }, + method: 'GET', + encodedPath: '/n/', + timeout: 10_000, + maxResponseBytes: 65_536, + }).catch((error: unknown) => error) + expect((failure as Error).message).toContain('[REDACTED]') + expect((failure as Error).message).not.toContain('+'.repeat(1024)) + }) + + it.each([ + encodeURIComponent('-----BEGIN PRIVATE KEY-----\ntruncated'), + encodeURIComponent(`${destination.origin}/n/truncated`), + ])('fails closed for encoded key or URL prefixes', async (message) => { + secureFetchMock.mockResolvedValueOnce( + secureResponse({ + ok: false, + status: 401, + body: JSON.stringify({ code: 'NotAuthenticated', message }), + }) + ) + const failure = await sendOciRequest({ + destination, + credentials, + method: 'GET', + encodedPath: '/n/', + timeout: 10_000, + maxResponseBytes: 65_536, + }).catch((error: unknown) => error) + expect((failure as Error).message).toBe('OCI request failed with status 401') + }) + + it('redacts generic and percent-encoded sensitive JSON fields', async () => { + const echoedSecrets = [ + 'access-value', + 'token-value', + 'secret-value', + 'password-value', + '(request-target) host x-date', + ] + secureFetchMock.mockResolvedValueOnce( + secureResponse({ + ok: false, + status: 401, + body: JSON.stringify({ + code: 'NotAuthenticated', + message: JSON.stringify({ + access_token: echoedSecrets[0], + token: echoedSecrets[1], + secret: echoedSecrets[2], + 'pass%70hrase': echoedSecrets[3], + signing_string: echoedSecrets[4], + }), + }), + }) + ) + const failure = await sendOciRequest({ + destination, + credentials, + method: 'GET', + encodedPath: '/n/', + timeout: 10_000, + maxResponseBytes: 65_536, + }).catch((error: unknown) => error) + for (const secret of echoedSecrets) expect((failure as Error).message).not.toContain(secret) + }) + + it.each([ + '{"authorization":"Signature version=\\"1\\",signature=\\"echoed\\"', + JSON.stringify({ level1: { level2: { level3: { authorization: 'echoed' } } } }), + JSON.stringify({ 'pass%25252570hrase': 'echoed' }), + ])('fails closed for malformed or over-depth structured diagnostics', async (message) => { + secureFetchMock.mockResolvedValueOnce( + secureResponse({ + ok: false, + status: 401, + body: JSON.stringify({ code: 'NotAuthenticated', message }), + }) + ) + const failure = await sendOciRequest({ + destination, + credentials, + method: 'GET', + encodedPath: '/n/', + timeout: 10_000, + maxResponseBytes: 65_536, + }).catch((error: unknown) => error) + expect((failure as Error).message).toBe('OCI request failed with status 401') + }) + it.each([ '//attacker.example/path', '/safe//attacker', diff --git a/apps/sim/lib/internal/oci/client.server.ts b/apps/sim/lib/internal/oci/client.server.ts index f6a32b91908..42466fc4ba0 100644 --- a/apps/sim/lib/internal/oci/client.server.ts +++ b/apps/sim/lib/internal/oci/client.server.ts @@ -62,7 +62,8 @@ function validateRequestLimits(timeout: number, maxResponseBytes: number): void function sensitiveRequestValues( credentials: OciSigningCredentials, - authorization: string | undefined + authorization: string | undefined, + requestUrl: string ): string[] { return [ credentials.tenancyId, @@ -71,6 +72,7 @@ function sensitiveRequestValues( credentials.privateKey, credentials.passphrase ?? '', authorization ?? '', + requestUrl, ].filter(Boolean) } @@ -116,7 +118,11 @@ export async function sendOciRequest(params: { const opcRequestId = response.headers.get('opc-request-id') ?? undefined if (response.ok) return { response, opcRequestId } - const sensitiveValues = sensitiveRequestValues(params.credentials, signed.headers.authorization) + const sensitiveValues = sensitiveRequestValues( + params.credentials, + signed.headers.authorization, + signed.url + ) const body = await response.text() const error = parseOciErrorBody(body, sensitiveValues) throw new OciRequestError({ diff --git a/apps/sim/lib/internal/oci/errors.ts b/apps/sim/lib/internal/oci/errors.ts index a74cd527cf2..e6aea6e2277 100644 --- a/apps/sim/lib/internal/oci/errors.ts +++ b/apps/sim/lib/internal/oci/errors.ts @@ -1,50 +1,91 @@ +import { + isSensitiveKey, + REDACTED_MARKER, + redactExactSensitiveValues, +} from '@/lib/core/security/redaction' + const MAX_OCI_ERROR_FIELD_LENGTH = 1024 -const MAX_OCI_ERROR_INPUT_LENGTH = 8192 +const MAX_OCI_ERROR_INPUT_LENGTH = 65_536 const MAX_NESTED_JSON_DEPTH = 3 -const SENSITIVE_JSON_FIELDS = new Set([ - 'authorization', - 'passphrase', - 'privatekey', - 'proxyauthorization', - 'signingstring', -]) +const OCI_SENSITIVE_JSON_FIELDS = new Set(['signingstring']) +const ENCODED_DIAGNOSTIC_SENTINELS = ['-----BEGIN', 'https://'] + +function normalizeJsonDiagnosticKey(key: string): string | undefined { + let normalized = key + for (let depth = 0; depth < MAX_NESTED_JSON_DEPTH; depth += 1) { + if (!normalized.includes('%')) return normalized + if (!/%[0-9a-f]{2}/i.test(normalized)) return undefined + try { + normalized = decodeURIComponent(normalized) + } catch { + return undefined + } + } + return normalized.includes('%') ? undefined : normalized +} + +function looksLikeStructuredJson(value: string): boolean { + const first = value.trimStart()[0] + return first === '{' || first === '[' || first === '"' +} + +function isSensitiveOciJsonKey(key: string): boolean { + const compactKey = key.replace(/[^a-z]/gi, '').toLowerCase() + return OCI_SENSITIVE_JSON_FIELDS.has(compactKey) || isSensitiveKey(key) +} + +function containsEncodedDiagnosticSentinel(value: string): boolean { + const lowerValue = value.toLowerCase() + return ENCODED_DIAGNOSTIC_SENTINELS.some((sentinel) => { + let encoded = sentinel + for (let depth = 0; depth < MAX_NESTED_JSON_DEPTH; depth += 1) { + encoded = encodeURIComponent(encoded) + if (encoded !== sentinel && lowerValue.includes(encoded.toLowerCase())) return true + } + return false + }) +} function flattenJsonDiagnostic(value: unknown, depth = 0): string | undefined { - if (depth > MAX_NESTED_JSON_DEPTH || value === null) return undefined - if (typeof value === 'string') return value + if (depth > MAX_NESTED_JSON_DEPTH) return undefined + if (value === null) return 'null' + if (typeof value === 'string') { + if (!looksLikeStructuredJson(value)) return value + if (depth === MAX_NESTED_JSON_DEPTH) return undefined + try { + return flattenJsonDiagnostic(JSON.parse(value), depth + 1) + } catch { + return undefined + } + } if (typeof value === 'number' || typeof value === 'boolean') return String(value) if (Array.isArray(value)) { - return value - .map((entry) => flattenJsonDiagnostic(entry, depth + 1)) - .filter((entry): entry is string => entry !== undefined) - .join(' ') + if (depth === MAX_NESTED_JSON_DEPTH) return undefined + const flattened = value.map((entry) => flattenJsonDiagnostic(entry, depth + 1)) + if (flattened.some((entry) => entry === undefined)) return undefined + return flattened.join(' ') } if (typeof value !== 'object') return undefined - return Object.entries(value) - .map(([key, entry]) => { - const normalizedKey = key.replace(/[^a-z]/gi, '').toLowerCase() - if (SENSITIVE_JSON_FIELDS.has(normalizedKey)) return `${key}: [redacted]` - const flattened = flattenJsonDiagnostic(entry, depth + 1) - return flattened === undefined ? undefined : `${key}: ${flattened}` - }) - .filter((entry): entry is string => entry !== undefined) - .join(' ') + if (depth === MAX_NESTED_JSON_DEPTH) return undefined + const flattened = Object.entries(value).map(([key, entry]) => { + const normalizedKey = normalizeJsonDiagnosticKey(key) + if (normalizedKey === undefined) return undefined + if (isSensitiveOciJsonKey(normalizedKey)) return `${key}: ${REDACTED_MARKER}` + const nested = flattenJsonDiagnostic(entry, depth + 1) + return nested === undefined ? undefined : `${key}: ${nested}` + }) + if (flattened.some((entry) => entry === undefined)) return undefined + return flattened.join(' ') } -function decodeNestedJsonDiagnostic(value: string): string { - let decoded = value.slice(0, MAX_OCI_ERROR_INPUT_LENGTH) - for (let depth = 0; depth < MAX_NESTED_JSON_DEPTH; depth += 1) { - let parsed: unknown - try { - parsed = JSON.parse(decoded) - } catch { - break - } - const flattened = flattenJsonDiagnostic(parsed) - if (flattened === undefined || flattened === decoded) break - decoded = flattened.slice(0, MAX_OCI_ERROR_INPUT_LENGTH) +function decodeNestedJsonDiagnostic(value: string): string | undefined { + if (value.length > MAX_OCI_ERROR_INPUT_LENGTH) return undefined + if (!looksLikeStructuredJson(value)) return value + try { + return flattenJsonDiagnostic(JSON.parse(value)) + } catch { + return undefined } - return decoded } function sanitizeOciErrorField( @@ -52,14 +93,26 @@ function sanitizeOciErrorField( sensitiveValues: readonly string[] = [] ): string | undefined { if (typeof value !== 'string') return undefined - let sanitized = decodeNestedJsonDiagnostic(value) + if (value.length > MAX_OCI_ERROR_INPUT_LENGTH) return undefined + if (containsEncodedDiagnosticSentinel(value)) return undefined + const decoded = decodeNestedJsonDiagnostic(value) + if (decoded === undefined) return undefined + const exactValues = sensitiveValues.flatMap((sensitiveValue) => { + const jsonEncoded = JSON.stringify(sensitiveValue).slice(1, -1) + return jsonEncoded === sensitiveValue ? [sensitiveValue] : [sensitiveValue, jsonEncoded] + }) + let exactRedacted: string + try { + exactRedacted = redactExactSensitiveValues(decoded, exactValues) + } catch { + return undefined + } + const sanitized = exactRedacted .replace(/-----BEGIN[\s\S]*/gi, '[redacted-key]') .replace(/https?:\/\/[^\s"']+/gi, '[redacted-url]') - .replace(/Signature\s+version="1",[^\r\n]*/gi, '[redacted-authorization]') - for (const sensitiveValue of sensitiveValues) { - if (sensitiveValue.length > 0) sanitized = sanitized.split(sensitiveValue).join('[redacted]') - } - sanitized = sanitized.replace(/[\u0000-\u001f\u007f]/g, ' ').trim() + .replace(/Signature\s+version=\\*"1\\*",[^\r\n]*/gi, '[redacted-authorization]') + .replace(/[\u0000-\u001f\u007f]/g, ' ') + .trim() return sanitized ? sanitized.slice(0, MAX_OCI_ERROR_FIELD_LENGTH) : undefined } From 60f00d191894c9b4b5bc2c120511a4f4d1d397c0 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos <157128530+BillLeoutsakosvl346@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:28:28 -0700 Subject: [PATCH 04/29] fix(oci): reject ambiguous diagnostics --- .../lib/internal/oci/client.server.test.ts | 52 ++++++++++++++++++- apps/sim/lib/internal/oci/client.server.ts | 1 + apps/sim/lib/internal/oci/errors.ts | 27 +++++----- 3 files changed, 65 insertions(+), 15 deletions(-) diff --git a/apps/sim/lib/internal/oci/client.server.test.ts b/apps/sim/lib/internal/oci/client.server.test.ts index 77553358300..e3e81a05ff5 100644 --- a/apps/sim/lib/internal/oci/client.server.test.ts +++ b/apps/sim/lib/internal/oci/client.server.test.ts @@ -232,7 +232,7 @@ describe('OCI request client', () => { maxResponseBytes: 65_536, }).catch((error: unknown) => error) expect(failure).toBeInstanceOf(OciRequestError) - expect((failure as Error).message).toContain('[REDACTED]') + expect((failure as Error).message).toBe('OCI request failed with status 401') expect((failure as Error).message).not.toContain('provider-echo') expect((failure as Error).message).not.toContain('(request-target)') expect((failure as Error).message).not.toContain('tenant/user/fingerprint') @@ -298,6 +298,8 @@ describe('OCI request client', () => { it.each([ encodeURIComponent('-----BEGIN PRIVATE KEY-----\ntruncated'), encodeURIComponent(`${destination.origin}/n/truncated`), + '----%2DBEGIN PRIVATE KEY-----', + 'https:%2F%2Fobjectstorage.us-ashburn-1.oraclecloud.com/n/', ])('fails closed for encoded key or URL prefixes', async (message) => { secureFetchMock.mockResolvedValueOnce( secureResponse({ @@ -324,6 +326,8 @@ describe('OCI request client', () => { 'secret-value', 'password-value', '(request-target) host x-date', + 'private-key-value', + 'api-key-value', ] secureFetchMock.mockResolvedValueOnce( secureResponse({ @@ -337,6 +341,8 @@ describe('OCI request client', () => { secret: echoedSecrets[2], 'pass%70hrase': echoedSecrets[3], signing_string: echoedSecrets[4], + 'private key': echoedSecrets[5], + 'api key': echoedSecrets[6], }), }), }) @@ -352,6 +358,50 @@ describe('OCI request client', () => { for (const secret of echoedSecrets) expect((failure as Error).message).not.toContain(secret) }) + it('fails closed when structured JSON follows a plain-text prefix', async () => { + const message = `provider failed: ${JSON.stringify({ authorization: 'provider-echo' })}` + secureFetchMock.mockResolvedValueOnce( + secureResponse({ + ok: false, + status: 401, + body: JSON.stringify({ code: 'NotAuthenticated', message }), + }) + ) + const failure = await sendOciRequest({ + destination, + credentials, + method: 'GET', + encodedPath: '/n/', + timeout: 10_000, + maxResponseBytes: 65_536, + }).catch((error: unknown) => error) + expect((failure as Error).message).toBe('OCI request failed with status 401') + }) + + it.each([ + `provider failed: ${JSON.stringify(JSON.stringify({ authorization: 'provider-echo' }))}`, + 'provider failed: \\"authorization\\":\\"provider-echo\\"', + 'signed headers: (request-target) host x-date', + 'signed headers: host x-content-sha256', + ])('fails closed for escaped structured or signing diagnostics', async (message) => { + secureFetchMock.mockResolvedValueOnce( + secureResponse({ + ok: false, + status: 401, + body: JSON.stringify({ code: 'NotAuthenticated', message }), + }) + ) + const failure = await sendOciRequest({ + destination, + credentials, + method: 'GET', + encodedPath: '/n/', + timeout: 10_000, + maxResponseBytes: 65_536, + }).catch((error: unknown) => error) + expect((failure as Error).message).toBe('OCI request failed with status 401') + }) + it.each([ '{"authorization":"Signature version=\\"1\\",signature=\\"echoed\\"', JSON.stringify({ level1: { level2: { level3: { authorization: 'echoed' } } } }), diff --git a/apps/sim/lib/internal/oci/client.server.ts b/apps/sim/lib/internal/oci/client.server.ts index 42466fc4ba0..6c51be0e5d1 100644 --- a/apps/sim/lib/internal/oci/client.server.ts +++ b/apps/sim/lib/internal/oci/client.server.ts @@ -69,6 +69,7 @@ function sensitiveRequestValues( credentials.tenancyId, credentials.userId, credentials.fingerprint, + credentials.fingerprint.toUpperCase(), credentials.privateKey, credentials.passphrase ?? '', authorization ?? '', diff --git a/apps/sim/lib/internal/oci/errors.ts b/apps/sim/lib/internal/oci/errors.ts index e6aea6e2277..13de92cbab7 100644 --- a/apps/sim/lib/internal/oci/errors.ts +++ b/apps/sim/lib/internal/oci/errors.ts @@ -8,7 +8,6 @@ const MAX_OCI_ERROR_FIELD_LENGTH = 1024 const MAX_OCI_ERROR_INPUT_LENGTH = 65_536 const MAX_NESTED_JSON_DEPTH = 3 const OCI_SENSITIVE_JSON_FIELDS = new Set(['signingstring']) -const ENCODED_DIAGNOSTIC_SENTINELS = ['-----BEGIN', 'https://'] function normalizeJsonDiagnosticKey(key: string): string | undefined { let normalized = key @@ -31,26 +30,24 @@ function looksLikeStructuredJson(value: string): boolean { function isSensitiveOciJsonKey(key: string): boolean { const compactKey = key.replace(/[^a-z]/gi, '').toLowerCase() - return OCI_SENSITIVE_JSON_FIELDS.has(compactKey) || isSensitiveKey(key) + return OCI_SENSITIVE_JSON_FIELDS.has(compactKey) || isSensitiveKey(compactKey) } -function containsEncodedDiagnosticSentinel(value: string): boolean { - const lowerValue = value.toLowerCase() - return ENCODED_DIAGNOSTIC_SENTINELS.some((sentinel) => { - let encoded = sentinel - for (let depth = 0; depth < MAX_NESTED_JSON_DEPTH; depth += 1) { - encoded = encodeURIComponent(encoded) - if (encoded !== sentinel && lowerValue.includes(encoded.toLowerCase())) return true - } - return false - }) +function containsEmbeddedStructuredText(value: string): boolean { + return ( + !looksLikeStructuredJson(value) && + (/[[{]\s*\\*(?:["{[\]}]|-?\d|true\b|false\b|null\b)/.test(value) || + /\\*"[^"\\\r\n]{1,128}\\*"\s*:\s*/.test(value)) + ) } function flattenJsonDiagnostic(value: unknown, depth = 0): string | undefined { if (depth > MAX_NESTED_JSON_DEPTH) return undefined if (value === null) return 'null' if (typeof value === 'string') { - if (!looksLikeStructuredJson(value)) return value + if (!looksLikeStructuredJson(value)) { + return containsEmbeddedStructuredText(value) ? undefined : value + } if (depth === MAX_NESTED_JSON_DEPTH) return undefined try { return flattenJsonDiagnostic(JSON.parse(value), depth + 1) @@ -80,6 +77,7 @@ function flattenJsonDiagnostic(value: unknown, depth = 0): string | undefined { function decodeNestedJsonDiagnostic(value: string): string | undefined { if (value.length > MAX_OCI_ERROR_INPUT_LENGTH) return undefined + if (containsEmbeddedStructuredText(value)) return undefined if (!looksLikeStructuredJson(value)) return value try { return flattenJsonDiagnostic(JSON.parse(value)) @@ -94,7 +92,8 @@ function sanitizeOciErrorField( ): string | undefined { if (typeof value !== 'string') return undefined if (value.length > MAX_OCI_ERROR_INPUT_LENGTH) return undefined - if (containsEncodedDiagnosticSentinel(value)) return undefined + if (/%[0-9a-f]{2}/i.test(value)) return undefined + if (/\(request-target\)|x-content-sha256/i.test(value)) return undefined const decoded = decodeNestedJsonDiagnostic(value) if (decoded === undefined) return undefined const exactValues = sensitiveValues.flatMap((sensitiveValue) => { From f7c0b63a7a5f0d09f52754be809722bb5a0bde5d Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos <157128530+BillLeoutsakosvl346@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:45:10 -0700 Subject: [PATCH 05/29] fix(oci): harden signed request boundaries --- .../lib/internal/oci/client.server.test.ts | 117 ++++++++++++++++-- apps/sim/lib/internal/oci/client.server.ts | 17 ++- apps/sim/lib/internal/oci/errors.ts | 1 + 3 files changed, 122 insertions(+), 13 deletions(-) diff --git a/apps/sim/lib/internal/oci/client.server.test.ts b/apps/sim/lib/internal/oci/client.server.test.ts index e3e81a05ff5..c9bc799f76c 100644 --- a/apps/sim/lib/internal/oci/client.server.test.ts +++ b/apps/sim/lib/internal/oci/client.server.test.ts @@ -211,8 +211,7 @@ describe('OCI request client', () => { }) it('redacts authorization material embedded in a serialized JSON message', async () => { - const echoedAuthorization = - 'Signature version="1",keyId="tenant/user/fingerprint",headers="(request-target) host x-date",signature="provider-echo"' + const echoedAuthorization = 'opaque-authorization-value' secureFetchMock.mockResolvedValueOnce( secureResponse({ ok: false, @@ -232,10 +231,8 @@ describe('OCI request client', () => { maxResponseBytes: 65_536, }).catch((error: unknown) => error) expect(failure).toBeInstanceOf(OciRequestError) - expect((failure as Error).message).toBe('OCI request failed with status 401') - expect((failure as Error).message).not.toContain('provider-echo') - expect((failure as Error).message).not.toContain('(request-target)') - expect((failure as Error).message).not.toContain('tenant/user/fingerprint') + expect((failure as Error).message).toContain('[REDACTED]') + expect((failure as Error).message).not.toContain(echoedAuthorization) }) it('redacts encoded credentials and request URLs echoed by the provider', async () => { @@ -268,6 +265,57 @@ describe('OCI request client', () => { expect((failure as Error).message).not.toContain(escapedPassphrase) }) + it('redacts an echoed finalized request body from provider diagnostics', async () => { + const requestBody = 'opaque-request-body-secret' + secureFetchMock.mockResolvedValueOnce( + secureResponse({ + ok: false, + status: 400, + body: JSON.stringify({ + code: 'InvalidParameter', + message: `provider echoed ${requestBody}`, + }), + }) + ) + const failure = await sendOciRequest({ + destination, + credentials, + method: 'POST', + encodedPath: '/n/', + timeout: 10_000, + maxResponseBytes: 65_536, + body: requestBody, + }).catch((error: unknown) => error) + expect((failure as Error).message).toContain('[REDACTED]') + expect((failure as Error).message).not.toContain(requestBody) + }) + + it('fails closed instead of redacting an unbounded request body', async () => { + const requestBody = 's'.repeat(65_537) + secureFetchMock.mockResolvedValueOnce( + secureResponse({ + ok: false, + status: 400, + opcRequestId: 'request-body-echo', + body: JSON.stringify({ + code: 'InvalidParameter', + message: `provider echoed ${requestBody.slice(0, 1024)}`, + }), + }) + ) + const failure = await sendOciRequest({ + destination, + credentials, + method: 'POST', + encodedPath: '/n/', + timeout: 10_000, + maxResponseBytes: 65_536, + body: requestBody, + }).catch((error: unknown) => error) + expect((failure as Error).message).toBe('OCI request failed with status 400') + expect((failure as OciRequestError).opcRequestId).toBeUndefined() + }) + it('redacts a maximum-size passphrase before bounding an encoded diagnostic', async () => { const longPassphrase = ' '.repeat(4096) const encodedPassphrase = new URLSearchParams({ value: longPassphrase }) @@ -319,13 +367,13 @@ describe('OCI request client', () => { expect((failure as Error).message).toBe('OCI request failed with status 401') }) - it('redacts generic and percent-encoded sensitive JSON fields', async () => { + it('redacts generic, spaced, and OCI-specific sensitive JSON fields', async () => { const echoedSecrets = [ 'access-value', 'token-value', 'secret-value', 'password-value', - '(request-target) host x-date', + 'signing-string-value', 'private-key-value', 'api-key-value', ] @@ -339,7 +387,7 @@ describe('OCI request client', () => { access_token: echoedSecrets[0], token: echoedSecrets[1], secret: echoedSecrets[2], - 'pass%70hrase': echoedSecrets[3], + passphrase: echoedSecrets[3], signing_string: echoedSecrets[4], 'private key': echoedSecrets[5], 'api key': echoedSecrets[6], @@ -355,9 +403,32 @@ describe('OCI request client', () => { timeout: 10_000, maxResponseBytes: 65_536, }).catch((error: unknown) => error) + expect((failure as Error).message).toContain('[REDACTED]') for (const secret of echoedSecrets) expect((failure as Error).message).not.toContain(secret) }) + it('fails closed for a percent-encoded sensitive JSON key', async () => { + secureFetchMock.mockResolvedValueOnce( + secureResponse({ + ok: false, + status: 401, + body: JSON.stringify({ + code: 'NotAuthenticated', + message: JSON.stringify({ 'pass%70hrase': 'provider-echo' }), + }), + }) + ) + const failure = await sendOciRequest({ + destination, + credentials, + method: 'GET', + encodedPath: '/n/', + timeout: 10_000, + maxResponseBytes: 65_536, + }).catch((error: unknown) => error) + expect((failure as Error).message).toBe('OCI request failed with status 401') + }) + it('fails closed when structured JSON follows a plain-text prefix', async () => { const message = `provider failed: ${JSON.stringify({ authorization: 'provider-echo' })}` secureFetchMock.mockResolvedValueOnce( @@ -402,6 +473,29 @@ describe('OCI request client', () => { expect((failure as Error).message).toBe('OCI request failed with status 401') }) + it.each([ + 'provider echoed \\u0028request-target\\u0029 host x-date', + 'provider echoed \\u0068ttps\\u003a\\u002f\\u002fexample.com', + 'provider echoed \\x28request-target\\x29', + ])('fails closed for Unicode-escaped diagnostics', async (message) => { + secureFetchMock.mockResolvedValueOnce( + secureResponse({ + ok: false, + status: 401, + body: JSON.stringify({ code: 'NotAuthenticated', message }), + }) + ) + const failure = await sendOciRequest({ + destination, + credentials, + method: 'GET', + encodedPath: '/n/', + timeout: 10_000, + maxResponseBytes: 65_536, + }).catch((error: unknown) => error) + expect((failure as Error).message).toBe('OCI request failed with status 401') + }) + it.each([ '{"authorization":"Signature version=\\"1\\",signature=\\"echoed\\"', JSON.stringify({ level1: { level2: { level3: { authorization: 'echoed' } } } }), @@ -432,6 +526,11 @@ describe('OCI request client', () => { '/path#fragment', '/path\\replacement', '/path%ZZ', + '/n/../tenant', + '/n/./tenant', + '/n/%2e/tenant', + '/n/%2E%2E/tenant', + '/n/.%2e/tenant', ])('rejects unsafe encoded paths: %s', (encodedPath) => { expect(() => buildOciRequestUrl(destination, encodedPath)).toThrow( 'single encoded absolute path' diff --git a/apps/sim/lib/internal/oci/client.server.ts b/apps/sim/lib/internal/oci/client.server.ts index 6c51be0e5d1..4773d27ce84 100644 --- a/apps/sim/lib/internal/oci/client.server.ts +++ b/apps/sim/lib/internal/oci/client.server.ts @@ -12,6 +12,7 @@ import { } from '@/lib/internal/oci/signing.server' const MAX_OCI_TIMEOUT_MS = 5 * 60 * 1000 +const MAX_OCI_REDACTABLE_BODY_LENGTH = 65_536 export interface OciRequestResult { readonly response: SecureFetchResponse @@ -43,6 +44,9 @@ export function buildOciRequestUrl( ) { throw new Error('OCI request path must be a single encoded absolute path') } + if (new URL(`${destination.origin}${encodedPath}`).pathname !== encodedPath) { + throw new Error('OCI request path must be a single encoded absolute path') + } const query = serializeOciQueryPairs(queryPairs) return `${destination.origin}${encodedPath}${query ? `?${query}` : ''}` } @@ -63,7 +67,8 @@ function validateRequestLimits(timeout: number, maxResponseBytes: number): void function sensitiveRequestValues( credentials: OciSigningCredentials, authorization: string | undefined, - requestUrl: string + requestUrl: string, + requestBody: string | undefined ): string[] { return [ credentials.tenancyId, @@ -74,6 +79,7 @@ function sensitiveRequestValues( credentials.passphrase ?? '', authorization ?? '', requestUrl, + requestBody ?? '', ].filter(Boolean) } @@ -119,18 +125,21 @@ export async function sendOciRequest(params: { const opcRequestId = response.headers.get('opc-request-id') ?? undefined if (response.ok) return { response, opcRequestId } + const requestBodyIsRedactable = + signed.body === undefined || signed.body.length <= MAX_OCI_REDACTABLE_BODY_LENGTH const sensitiveValues = sensitiveRequestValues( params.credentials, signed.headers.authorization, - signed.url + signed.url, + requestBodyIsRedactable ? signed.body : undefined ) const body = await response.text() - const error = parseOciErrorBody(body, sensitiveValues) + const error = requestBodyIsRedactable ? parseOciErrorBody(body, sensitiveValues) : {} throw new OciRequestError({ status: response.status, code: error.code, message: error.message, - opcRequestId, + opcRequestId: requestBodyIsRedactable ? opcRequestId : undefined, sensitiveValues, }) } diff --git a/apps/sim/lib/internal/oci/errors.ts b/apps/sim/lib/internal/oci/errors.ts index 13de92cbab7..8e10a0fb0ab 100644 --- a/apps/sim/lib/internal/oci/errors.ts +++ b/apps/sim/lib/internal/oci/errors.ts @@ -93,6 +93,7 @@ function sanitizeOciErrorField( if (typeof value !== 'string') return undefined if (value.length > MAX_OCI_ERROR_INPUT_LENGTH) return undefined if (/%[0-9a-f]{2}/i.test(value)) return undefined + if (/\\(?:u[0-9a-f]{4}|x[0-9a-f]{2})/i.test(value)) return undefined if (/\(request-target\)|x-content-sha256/i.test(value)) return undefined const decoded = decodeNestedJsonDiagnostic(value) if (decoded === undefined) return undefined From c9eecff4fd219f8e463167bf5fe187edcea6670f Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos <157128530+BillLeoutsakosvl346@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:10:08 -0700 Subject: [PATCH 06/29] fix(oci): bound and sanitize provider errors --- .../lib/internal/oci/client.server.test.ts | 120 ++++++++++++++++-- apps/sim/lib/internal/oci/client.server.ts | 76 +++++++++-- apps/sim/lib/internal/oci/errors.ts | 22 +++- 3 files changed, 195 insertions(+), 23 deletions(-) diff --git a/apps/sim/lib/internal/oci/client.server.test.ts b/apps/sim/lib/internal/oci/client.server.test.ts index c9bc799f76c..4f1917771c4 100644 --- a/apps/sim/lib/internal/oci/client.server.test.ts +++ b/apps/sim/lib/internal/oci/client.server.test.ts @@ -24,6 +24,7 @@ function secureResponse(params: { ok: boolean status: number body?: string + responseBody?: ReadableStream | null opcRequestId?: string }) { return { @@ -34,7 +35,7 @@ function secureResponse(params: { get: (name: string) => name.toLowerCase() === 'opc-request-id' ? (params.opcRequestId ?? null) : null, }, - body: null, + body: params.responseBody ?? null, text: vi.fn().mockResolvedValue(params.body ?? ''), json: vi.fn(), arrayBuffer: vi.fn(), @@ -235,19 +236,17 @@ describe('OCI request client', () => { expect((failure as Error).message).not.toContain(echoedAuthorization) }) - it('redacts encoded credentials and request URLs echoed by the provider', async () => { + it('redacts encoded credentials instead of falling through to a status-only error', async () => { const encodedFingerprint = encodeURIComponent(credentials.fingerprint) - const requestUrl = `${destination.origin}/n/` - const encodedRequestUrl = encodeURIComponent(requestUrl) const escapedPassphrase = 'secret "pass"' - const escapedPassphraseEcho = JSON.stringify(escapedPassphrase).slice(1, -1) + const encodedPassphrase = encodeURIComponent(escapedPassphrase) secureFetchMock.mockResolvedValueOnce( secureResponse({ ok: false, status: 401, body: JSON.stringify({ code: 'NotAuthenticated', - message: `provider echoed ${encodedFingerprint} ${encodedRequestUrl} ${escapedPassphraseEcho}`, + message: `provider echoed ${encodedFingerprint} ${encodedPassphrase}`, }), }) ) @@ -259,12 +258,63 @@ describe('OCI request client', () => { timeout: 10_000, maxResponseBytes: 65_536, }).catch((error: unknown) => error) + expect((failure as Error).message).toContain('provider echoed') + expect((failure as Error).message).toContain('[REDACTED]') expect((failure as Error).message).not.toContain(encodedFingerprint) - expect((failure as Error).message).not.toContain(encodedRequestUrl) - expect((failure as Error).message).not.toContain(escapedPassphraseEcho) + expect((failure as Error).message).not.toContain(encodedPassphrase) expect((failure as Error).message).not.toContain(escapedPassphrase) }) + it('redacts an encoded signed request URL instead of returning it', async () => { + const encodedRequestUrl = encodeURIComponent(`${destination.origin}/n/`) + secureFetchMock.mockResolvedValueOnce( + secureResponse({ + ok: false, + status: 401, + body: JSON.stringify({ + code: 'NotAuthenticated', + message: `provider echoed ${encodedRequestUrl}`, + }), + }) + ) + const failure = await sendOciRequest({ + destination, + credentials, + method: 'GET', + encodedPath: '/n/', + timeout: 10_000, + maxResponseBytes: 65_536, + }).catch((error: unknown) => error) + expect((failure as Error).message).toContain('provider echoed') + expect((failure as Error).message).toContain('[REDACTED]') + expect((failure as Error).message).not.toContain(encodedRequestUrl) + }) + + it('redacts caller-supplied service header values echoed by the provider', async () => { + const serviceHeaderSecret = 'opaque-service-header-secret' + secureFetchMock.mockResolvedValueOnce( + secureResponse({ + ok: false, + status: 401, + body: JSON.stringify({ + code: 'NotAuthenticated', + message: `provider echoed ${serviceHeaderSecret}`, + }), + }) + ) + const failure = await sendOciRequest({ + destination, + credentials, + method: 'GET', + encodedPath: '/n/', + timeout: 10_000, + maxResponseBytes: 65_536, + serviceHeaders: { 'opc-client-info': serviceHeaderSecret }, + }).catch((error: unknown) => error) + expect((failure as Error).message).toContain('[REDACTED]') + expect((failure as Error).message).not.toContain(serviceHeaderSecret) + }) + it('redacts an echoed finalized request body from provider diagnostics', async () => { const requestBody = 'opaque-request-body-secret' secureFetchMock.mockResolvedValueOnce( @@ -376,6 +426,7 @@ describe('OCI request client', () => { 'signing-string-value', 'private-key-value', 'api-key-value', + 'signature-value', ] secureFetchMock.mockResolvedValueOnce( secureResponse({ @@ -391,6 +442,7 @@ describe('OCI request client', () => { signing_string: echoedSecrets[4], 'private key': echoedSecrets[5], 'api key': echoedSecrets[6], + signature: echoedSecrets[7], }), }), }) @@ -407,6 +459,58 @@ describe('OCI request client', () => { for (const secret of echoedSecrets) expect((failure as Error).message).not.toContain(secret) }) + it('fails closed for authorization signatures with flexible parameter spacing', async () => { + const echoedSignature = 'unknown-provider-signature' + secureFetchMock.mockResolvedValueOnce( + secureResponse({ + ok: false, + status: 401, + body: JSON.stringify({ + code: 'NotAuthenticated', + message: `provider echoed Signature version = "1", keyId = "unknown", signature = "${echoedSignature}"`, + }), + }) + ) + const failure = await sendOciRequest({ + destination, + credentials, + method: 'GET', + encodedPath: '/n/', + timeout: 10_000, + maxResponseBytes: 65_536, + }).catch((error: unknown) => error) + expect((failure as Error).message).toBe('OCI request failed with status 401') + expect((failure as Error).message).not.toContain(echoedSignature) + }) + + it('bounds non-success response bodies independently of the caller response ceiling', async () => { + const cancel = vi.fn() + const response = secureResponse({ + ok: false, + status: 502, + opcRequestId: 'request-oversized', + responseBody: new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(65_537)) + }, + cancel, + }), + }) + secureFetchMock.mockResolvedValueOnce(response) + const failure = await sendOciRequest({ + destination, + credentials, + method: 'GET', + encodedPath: '/n/', + timeout: 10_000, + maxResponseBytes: 1024 * 1024, + }).catch((error: unknown) => error) + expect((failure as Error).message).toBe('OCI request failed with status 502') + expect((failure as OciRequestError).opcRequestId).toBe('request-oversized') + expect(cancel).toHaveBeenCalledOnce() + expect(response.text).not.toHaveBeenCalled() + }) + it('fails closed for a percent-encoded sensitive JSON key', async () => { secureFetchMock.mockResolvedValueOnce( secureResponse({ diff --git a/apps/sim/lib/internal/oci/client.server.ts b/apps/sim/lib/internal/oci/client.server.ts index 4773d27ce84..8b15b349929 100644 --- a/apps/sim/lib/internal/oci/client.server.ts +++ b/apps/sim/lib/internal/oci/client.server.ts @@ -3,6 +3,10 @@ import { type SecureFetchResponse, secureFetchWithValidation, } from '@/lib/core/security/input-validation.server' +import { + DEFAULT_MAX_ERROR_BODY_BYTES, + readResponseTextWithLimit, +} from '@/lib/core/utils/stream-limits' import type { ValidatedOciDestination } from '@/lib/internal/oci/endpoints' import { OciRequestError, parseOciErrorBody } from '@/lib/internal/oci/errors' import { @@ -12,7 +16,7 @@ import { } from '@/lib/internal/oci/signing.server' const MAX_OCI_TIMEOUT_MS = 5 * 60 * 1000 -const MAX_OCI_REDACTABLE_BODY_LENGTH = 65_536 +const MAX_OCI_REDACTABLE_REQUEST_MATERIAL_LENGTH = 65_536 export interface OciRequestResult { readonly response: SecureFetchResponse @@ -68,7 +72,8 @@ function sensitiveRequestValues( credentials: OciSigningCredentials, authorization: string | undefined, requestUrl: string, - requestBody: string | undefined + requestBody: string | undefined, + serviceHeaderValues: readonly string[] ): string[] { return [ credentials.tenancyId, @@ -80,9 +85,51 @@ function sensitiveRequestValues( authorization ?? '', requestUrl, requestBody ?? '', + ...serviceHeaderValues, ].filter(Boolean) } +function getSignedServiceHeaderValues( + serviceHeaders: Readonly> | undefined, + signedHeaders: Readonly> +): string[] { + return Object.keys(serviceHeaders ?? {}).flatMap((name) => { + const value = signedHeaders[name.toLowerCase()] + return value === undefined ? [] : [value] + }) +} + +function isRedactableRequestMaterial(values: readonly (string | undefined)[]): boolean { + let totalLength = 0 + for (const value of values) { + if (value === undefined) continue + totalLength += value.length + if (totalLength > MAX_OCI_REDACTABLE_REQUEST_MATERIAL_LENGTH) return false + } + return true +} + +async function readOciErrorBody( + response: SecureFetchResponse, + method: OciRequestMethod, + maxResponseBytes: number, + signal: AbortSignal | undefined +): Promise { + try { + return await readResponseTextWithLimit(response, { + maxBytes: Math.min(DEFAULT_MAX_ERROR_BODY_BYTES, maxResponseBytes), + label: 'OCI error response', + signal, + allowNoBodyFallback: true, + requestMethod: method, + }) + } catch (error) { + if (signal?.aborted) throw error + await response.body?.cancel().catch(() => {}) + return undefined + } +} + /** Sends one bounded, redirect-free OCI request to an already validated destination. */ export async function sendOciRequest(params: { destination: ValidatedOciDestination @@ -125,21 +172,34 @@ export async function sendOciRequest(params: { const opcRequestId = response.headers.get('opc-request-id') ?? undefined if (response.ok) return { response, opcRequestId } - const requestBodyIsRedactable = - signed.body === undefined || signed.body.length <= MAX_OCI_REDACTABLE_BODY_LENGTH + const serviceHeaderValues = getSignedServiceHeaderValues(params.serviceHeaders, signed.headers) + const requestMaterialIsRedactable = isRedactableRequestMaterial([ + signed.body, + ...serviceHeaderValues, + ]) + if (!requestMaterialIsRedactable) { + await response.body?.cancel().catch(() => {}) + throw new OciRequestError({ status: response.status }) + } const sensitiveValues = sensitiveRequestValues( params.credentials, signed.headers.authorization, signed.url, - requestBodyIsRedactable ? signed.body : undefined + signed.body, + serviceHeaderValues + ) + const body = await readOciErrorBody( + response, + signed.method, + params.maxResponseBytes, + params.signal ) - const body = await response.text() - const error = requestBodyIsRedactable ? parseOciErrorBody(body, sensitiveValues) : {} + const error = body === undefined ? {} : parseOciErrorBody(body, sensitiveValues) throw new OciRequestError({ status: response.status, code: error.code, message: error.message, - opcRequestId: requestBodyIsRedactable ? opcRequestId : undefined, + opcRequestId, sensitiveValues, }) } diff --git a/apps/sim/lib/internal/oci/errors.ts b/apps/sim/lib/internal/oci/errors.ts index 8e10a0fb0ab..020e4b74afc 100644 --- a/apps/sim/lib/internal/oci/errors.ts +++ b/apps/sim/lib/internal/oci/errors.ts @@ -2,12 +2,13 @@ import { isSensitiveKey, REDACTED_MARKER, redactExactSensitiveValues, + redactKnownSensitiveValues, } from '@/lib/core/security/redaction' const MAX_OCI_ERROR_FIELD_LENGTH = 1024 const MAX_OCI_ERROR_INPUT_LENGTH = 65_536 const MAX_NESTED_JSON_DEPTH = 3 -const OCI_SENSITIVE_JSON_FIELDS = new Set(['signingstring']) +const OCI_SENSITIVE_JSON_FIELDS = new Set(['signature', 'signingstring']) function normalizeJsonDiagnosticKey(key: string): string | undefined { let normalized = key @@ -92,15 +93,22 @@ function sanitizeOciErrorField( ): string | undefined { if (typeof value !== 'string') return undefined if (value.length > MAX_OCI_ERROR_INPUT_LENGTH) return undefined - if (/%[0-9a-f]{2}/i.test(value)) return undefined - if (/\\(?:u[0-9a-f]{4}|x[0-9a-f]{2})/i.test(value)) return undefined - if (/\(request-target\)|x-content-sha256/i.test(value)) return undefined - const decoded = decodeNestedJsonDiagnostic(value) - if (decoded === undefined) return undefined const exactValues = sensitiveValues.flatMap((sensitiveValue) => { const jsonEncoded = JSON.stringify(sensitiveValue).slice(1, -1) return jsonEncoded === sensitiveValue ? [sensitiveValue] : [sensitiveValue, jsonEncoded] }) + let knownRedacted: string + try { + knownRedacted = redactKnownSensitiveValues(value, exactValues) + } catch { + return undefined + } + if (/%[0-9a-f]{2}/i.test(knownRedacted)) return undefined + if (/\\(?:u[0-9a-f]{4}|x[0-9a-f]{2})/i.test(knownRedacted)) return undefined + if (/\(request-target\)|x-content-sha256/i.test(knownRedacted)) return undefined + if (/\bsignature\s*(?:version\s*)?=/i.test(knownRedacted)) return undefined + const decoded = decodeNestedJsonDiagnostic(knownRedacted) + if (decoded === undefined) return undefined let exactRedacted: string try { exactRedacted = redactExactSensitiveValues(decoded, exactValues) @@ -110,7 +118,7 @@ function sanitizeOciErrorField( const sanitized = exactRedacted .replace(/-----BEGIN[\s\S]*/gi, '[redacted-key]') .replace(/https?:\/\/[^\s"']+/gi, '[redacted-url]') - .replace(/Signature\s+version=\\*"1\\*",[^\r\n]*/gi, '[redacted-authorization]') + .replace(/Signature\s+version\s*=\s*\\*"1\\*"\s*,[^\r\n]*/gi, '[redacted-authorization]') .replace(/[\u0000-\u001f\u007f]/g, ' ') .trim() return sanitized ? sanitized.slice(0, MAX_OCI_ERROR_FIELD_LENGTH) : undefined From 3e476e7e3a4711dc44f7753e845f9c7eaa3dab40 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Thu, 3 Sep 2026 19:06:07 -0700 Subject: [PATCH 07/29] refactor(oci): bind requests to authorized credentials --- apps/sim/lib/internal/oci/client.server.ts | 1017 ++++++++++++++--- apps/sim/lib/internal/oci/endpoints.ts | 179 ++- apps/sim/lib/internal/oci/errors.ts | 199 +--- .../lib/internal/oci/signing.server.test.ts | 225 ---- apps/sim/lib/internal/oci/signing.server.ts | 104 -- apps/sim/lib/oauth/credential-service.ts | 4 + apps/sim/lib/oauth/token-resolution.ts | 58 +- apps/sim/lib/oauth/types.ts | 4 + apps/sim/lib/selectors/server/credentials.ts | 4 +- apps/sim/package.json | 1 - bun.lock | 57 - 11 files changed, 1110 insertions(+), 742 deletions(-) delete mode 100644 apps/sim/lib/internal/oci/signing.server.test.ts delete mode 100644 apps/sim/lib/internal/oci/signing.server.ts diff --git a/apps/sim/lib/internal/oci/client.server.ts b/apps/sim/lib/internal/oci/client.server.ts index 8b15b349929..45f8082e204 100644 --- a/apps/sim/lib/internal/oci/client.server.ts +++ b/apps/sim/lib/internal/oci/client.server.ts @@ -1,3 +1,18 @@ +import { + createHash, + createPrivateKey, + createPublicKey, + createSign, + type KeyObject, +} from 'node:crypto' +import { db } from '@sim/db' +import { credential } from '@sim/db/schema' +import { safeCompare } from '@sim/security/compare' +import { toError } from '@sim/utils/errors' +import { sleep } from '@sim/utils/helpers' +import { backoffWithJitter, parseRetryAfter } from '@sim/utils/retry' +import { and, eq } from 'drizzle-orm' +import { decryptSecret } from '@/lib/core/security/encryption' import { DEFAULT_MAX_RESPONSE_BYTES, type SecureFetchResponse, @@ -5,201 +20,907 @@ import { } from '@/lib/core/security/input-validation.server' import { DEFAULT_MAX_ERROR_BODY_BYTES, - readResponseTextWithLimit, + isPayloadSizeLimitError, + readResponseToBufferWithLimit, } from '@/lib/core/utils/stream-limits' -import type { ValidatedOciDestination } from '@/lib/internal/oci/endpoints' -import { OciRequestError, parseOciErrorBody } from '@/lib/internal/oci/errors' import { - type OciRequestMethod, - type OciSigningCredentials, - signOciRequest, -} from '@/lib/internal/oci/signing.server' + createOciStaticEndpointPolicy, + type OciDiscoveredEndpointPolicy, + type OciEndpointPolicy, + type OciPreparedEndpoint, + type OciRegion, + type OciStaticEndpointPolicy, + resolveDiscoveredOciEndpoint, + resolveEffectiveOciRegion, + resolveStaticOciEndpoint, +} from '@/lib/internal/oci/endpoints' +import { OciClientError } from '@/lib/internal/oci/errors' +import { + type OAuthService, + OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID, + OCI_API_KEY_SERVICE_ACCOUNT_SECRET_TYPE, + OCI_SERVICE_ID, +} from '@/lib/oauth/types' +import { getServiceConfigByServiceId } from '@/lib/oauth/utils' + +export type OciRequestMethod = 'GET' | 'HEAD' | 'DELETE' | 'POST' | 'PUT' | 'PATCH' -const MAX_OCI_TIMEOUT_MS = 5 * 60 * 1000 -const MAX_OCI_REDACTABLE_REQUEST_MATERIAL_LENGTH = 65_536 +export type OciRetryPolicy = + | { readonly kind: 'safe'; readonly maxAttempts: number } + | { readonly kind: 'tokenized'; readonly maxAttempts: number; readonly retryToken: string } + +export interface OciRequest { + readonly endpoint: OciPreparedEndpoint + readonly method: OciRequestMethod + readonly encodedPath: string + readonly queryPairs?: readonly (readonly [string, string])[] + readonly headers?: Readonly> + readonly body?: Uint8Array + readonly contentType?: string + readonly timeoutMs: number + readonly maxResponseBytes: number + readonly responseHeaders?: readonly string[] + readonly retry?: OciRetryPolicy + readonly signal?: AbortSignal +} -export interface OciRequestResult { - readonly response: SecureFetchResponse +declare const authenticatedOciResponseBrand: unique symbol + +export interface OciAuthenticatedResponse { + readonly status: number + readonly headers: Readonly> readonly opcRequestId?: string + readonly body: Uint8Array + readonly [authenticatedOciResponseBrand]: true +} + +export interface OciClient { + prepareStaticEndpoint(policy: OciStaticEndpointPolicy): Promise + prepareDiscoveredEndpoint( + policy: OciDiscoveredEndpointPolicy, + response: OciAuthenticatedResponse + ): Promise + request(request: OciRequest): Promise +} + +/** + * Trusted binding supplied by a server-side operation after normal credential + * authorization. `credentialId` must be `authz.resolvedCredentialId` (or the + * selector equivalent), and `workspaceId` must come from the operation's + * trusted execution context. A caller-controlled database ID is not authority. + */ +export interface CreateOciClientParams { + readonly credentialId: string + readonly workspaceId: string + readonly serviceId: OAuthService + readonly region?: string +} + +interface OciCredentialMaterial { + readonly tenancyOcid: string + readonly userOcid: string + readonly fingerprint: string + readonly privateKey: KeyObject + readonly region: string +} + +interface BoundResponseSnapshot { + readonly status: number + readonly headers: Readonly> + readonly body: Uint8Array + readonly region: OciRegion + readonly policy: OciEndpointPolicy +} + +interface SignedOciRequest { + readonly url: string + readonly headers: Readonly> + readonly body?: Uint8Array +} + +const BODY_METHODS: ReadonlySet = new Set(['POST', 'PUT', 'PATCH']) +const REQUEST_METHODS: ReadonlySet = new Set([ + 'GET', + 'HEAD', + 'DELETE', + 'POST', + 'PUT', + 'PATCH', +]) +const SIGNING_CONTROLLED_HEADERS: ReadonlySet = new Set([ + 'authorization', + 'host', + 'date', + 'x-date', + 'content-length', + 'content-type', + 'x-content-sha256', +]) +const RESPONSE_HEADER_ALLOWLIST: ReadonlySet = new Set([ + 'content-type', + 'etag', + 'location', + 'opc-next-page', + 'opc-request-id', + 'opc-work-request-id', + 'retry-after', +]) +const RETRYABLE_STATUSES: ReadonlySet = new Set([429, 500, 502, 503, 504]) +const RETRYABLE_TRANSPORT_CODES: ReadonlySet = new Set([ + 'ECONNRESET', + 'ECONNREFUSED', + 'EHOSTUNREACH', + 'ENETDOWN', + 'ENETUNREACH', + 'ETIMEDOUT', +]) +const MAX_OCID_LENGTH = 255 +const MAX_PRIVATE_KEY_BYTES = 64 * 1024 +const MAX_PASSPHRASE_BYTES = 4 * 1024 +const MAX_TIMEOUT_MS = 5 * 60 * 1000 +const MAX_ATTEMPTS = 5 +const MAX_RETRY_TOKEN_BYTES = 512 +const SETUP_VERIFICATION_TIMEOUT_MS = 10_000 +const SETUP_VERIFICATION_RESPONSE_BYTES = 64 * 1024 +const OCID_PATTERN = /^ocid1\.([a-z][a-z0-9_-]*)\.([a-z0-9]+)\.([a-z0-9-]*)\.([a-zA-Z0-9_-]+)$/ +const CONTROL_PATTERN = /[\u0000-\u001f\u007f]/ +const PEM_CONTROL_PATTERN = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/ + +function credentialUnavailable(): OciClientError { + return new OciClientError('credential_unavailable') +} + +function assertExactKeys( + record: Record, + required: readonly string[], + optional: readonly string[] = [] +): void { + const keys = Object.keys(record) + if ( + required.some((key) => !Object.hasOwn(record, key)) || + keys.some((key) => !required.includes(key) && !optional.includes(key)) + ) { + throw credentialUnavailable() + } +} + +function parseOcid(value: unknown, type: 'tenancy' | 'user'): { value: string; realm: string } { + if ( + typeof value !== 'string' || + value !== value.trim() || + value.length === 0 || + Buffer.byteLength(value, 'utf8') > MAX_OCID_LENGTH || + CONTROL_PATTERN.test(value) + ) { + throw credentialUnavailable() + } + const match = OCID_PATTERN.exec(value) + if (!match || match[1] !== type) throw credentialUnavailable() + return { value, realm: match[2] } +} + +function normalizeFingerprint(value: unknown): string { + if (typeof value !== 'string' || value.length > 128 || CONTROL_PATTERN.test(value)) { + throw credentialUnavailable() + } + const hex = value.replace(/[:\s]/g, '').toLowerCase() + const bytes = /^[0-9a-f]{32}$/.test(hex) ? hex.match(/.{2}/g) : null + if (!bytes) throw credentialUnavailable() + return bytes.join(':') } -function encodeRfc3986(value: string): string { - return encodeURIComponent(value).replace( - /[!'()*]/g, - (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}` +function parseCredentialMaterial(serialized: string): OciCredentialMaterial { + let parsed: unknown + try { + parsed = JSON.parse(serialized) + } catch { + throw credentialUnavailable() + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw credentialUnavailable() + } + const record = parsed as Record + assertExactKeys( + record, + [ + 'type', + 'providerId', + 'tenancyOcid', + 'userOcid', + 'fingerprint', + 'privateKey', + 'region', + 'metadata', + ], + ['privateKeyPassphrase'] ) + if ( + record.type !== OCI_API_KEY_SERVICE_ACCOUNT_SECRET_TYPE || + record.providerId !== OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID || + !record.metadata || + typeof record.metadata !== 'object' || + Array.isArray(record.metadata) + ) { + throw credentialUnavailable() + } + const metadata = record.metadata as Record + assertExactKeys(metadata, ['principalKind', 'principalId']) + const tenancy = parseOcid(record.tenancyOcid, 'tenancy') + const user = parseOcid(record.userOcid, 'user') + if (tenancy.realm !== user.realm) throw credentialUnavailable() + + const fingerprint = normalizeFingerprint(record.fingerprint) + if (record.fingerprint !== fingerprint) throw credentialUnavailable() + if ( + typeof record.privateKey !== 'string' || + record.privateKey.length === 0 || + Buffer.byteLength(record.privateKey, 'utf8') > MAX_PRIVATE_KEY_BYTES || + PEM_CONTROL_PATTERN.test(record.privateKey) + ) { + throw credentialUnavailable() + } + const normalizedPrivateKey = `${record.privateKey.replace(/\r\n?/g, '\n').trim()}\n` + if (record.privateKey !== normalizedPrivateKey) throw credentialUnavailable() + + let passphrase: string | undefined + if (Object.hasOwn(record, 'privateKeyPassphrase')) { + if ( + typeof record.privateKeyPassphrase !== 'string' || + Buffer.byteLength(record.privateKeyPassphrase, 'utf8') > MAX_PASSPHRASE_BYTES || + CONTROL_PATTERN.test(record.privateKeyPassphrase) + ) { + throw credentialUnavailable() + } + passphrase = record.privateKeyPassphrase + } + if ( + typeof record.region !== 'string' || + record.region !== record.region.trim().toLowerCase() || + metadata.principalKind !== 'user' || + metadata.principalId !== user.value + ) { + throw credentialUnavailable() + } + const region = resolveEffectiveOciRegion(record.region) + if (region.realm.id !== tenancy.realm) throw credentialUnavailable() + + let privateKey: KeyObject + try { + privateKey = createPrivateKey({ + key: normalizedPrivateKey, + format: 'pem', + ...(passphrase !== undefined ? { passphrase } : {}), + }) + } catch { + throw credentialUnavailable() + } + if ( + privateKey.asymmetricKeyType !== 'rsa' || + privateKey.asymmetricKeyDetails?.modulusLength === undefined || + privateKey.asymmetricKeyDetails.modulusLength < 2048 + ) { + throw credentialUnavailable() + } + const publicKey = createPublicKey(privateKey).export({ format: 'der', type: 'spki' }) + const derivedFingerprint = createHash('md5').update(publicKey).digest() + const submittedFingerprint = Buffer.from(fingerprint.replaceAll(':', ''), 'hex') + if ( + !safeCompare(derivedFingerprint.toString('base64'), submittedFingerprint.toString('base64')) + ) { + throw credentialUnavailable() + } + + return { + tenancyOcid: tenancy.value, + userOcid: user.value, + fingerprint, + privateKey, + region: region.id, + } +} + +async function loadCredentialMaterial(params: { + credentialId: string + workspaceId: string +}): Promise { + try { + const [row] = await db + .select({ encryptedServiceAccountKey: credential.encryptedServiceAccountKey }) + .from(credential) + .where( + and( + eq(credential.id, params.credentialId), + eq(credential.workspaceId, params.workspaceId), + eq(credential.type, 'service_account'), + eq(credential.providerId, OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID) + ) + ) + .limit(1) + if (!row?.encryptedServiceAccountKey) throw credentialUnavailable() + const { decrypted } = await decryptSecret(row.encryptedServiceAccountKey) + return parseCredentialMaterial(decrypted) + } catch { + throw credentialUnavailable() + } } -export function serializeOciQueryPairs(pairs: readonly (readonly [string, string])[]): string { - return pairs.map(([key, value]) => `${encodeRfc3986(key)}=${encodeRfc3986(value)}`).join('&') +function serializeQueryPairs(pairs: readonly (readonly [string, string])[]): string { + const encode = (value: string) => + (() => { + try { + return encodeURIComponent(value).replace( + /[!'()*]/g, + (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}` + ) + } catch { + throw new OciClientError('invalid_request') + } + })() + return pairs.map(([key, value]) => `${encode(key)}=${encode(value)}`).join('&') } -export function buildOciRequestUrl( - destination: ValidatedOciDestination, +function buildRequestUrl( + endpoint: OciPreparedEndpoint, encodedPath: string, - queryPairs: readonly (readonly [string, string])[] = [] + queryPairs: readonly (readonly [string, string])[] ): string { if ( + typeof encodedPath !== 'string' || !encodedPath.startsWith('/') || encodedPath.startsWith('//') || encodedPath.includes('//') || /[?#\\\u0000-\u001f\u007f]/.test(encodedPath) || + /%(?:0[0-9a-f]|1[0-9a-f]|2f|5c|7f)/i.test(encodedPath) || /%(?![0-9a-f]{2})/i.test(encodedPath) ) { - throw new Error('OCI request path must be a single encoded absolute path') + throw new OciClientError('invalid_request') } - if (new URL(`${destination.origin}${encodedPath}`).pathname !== encodedPath) { - throw new Error('OCI request path must be a single encoded absolute path') + let url: URL + try { + url = new URL(`${endpoint.origin}${encodedPath}`) + } catch { + throw new OciClientError('invalid_request') + } + if (url.pathname !== encodedPath) throw new OciClientError('invalid_request') + const query = serializeQueryPairs(queryPairs) + return `${endpoint.origin}${encodedPath}${query ? `?${query}` : ''}` +} + +function validateHeaders(headers: Readonly>): Record { + const normalized: Record = {} + for (const [name, value] of Object.entries(headers)) { + const lowerName = name.toLowerCase() + if ( + SIGNING_CONTROLLED_HEADERS.has(lowerName) || + lowerName === 'opc-retry-token' || + Object.hasOwn(normalized, lowerName) || + !/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(name) || + typeof value !== 'string' || + CONTROL_PATTERN.test(value) + ) { + throw new OciClientError('invalid_request') + } + normalized[lowerName] = value } - const query = serializeOciQueryPairs(queryPairs) - return `${destination.origin}${encodedPath}${query ? `?${query}` : ''}` + return normalized } -function validateRequestLimits(timeout: number, maxResponseBytes: number): void { - if (!Number.isSafeInteger(timeout) || timeout <= 0 || timeout > MAX_OCI_TIMEOUT_MS) { - throw new Error('OCI timeout is outside the supported range') +function validateRequest(request: OciRequest): { + body?: Uint8Array + headers: Record + queryPairs: readonly (readonly [string, string])[] + attempts: number + retryToken?: string +} { + if ( + !REQUEST_METHODS.has(request.method) || + typeof request.encodedPath !== 'string' || + !Number.isSafeInteger(request.timeoutMs) || + request.timeoutMs <= 0 || + request.timeoutMs > MAX_TIMEOUT_MS || + !Number.isSafeInteger(request.maxResponseBytes) || + request.maxResponseBytes <= 0 || + request.maxResponseBytes > DEFAULT_MAX_RESPONSE_BYTES + ) { + throw new OciClientError('invalid_request') } if ( - !Number.isSafeInteger(maxResponseBytes) || - maxResponseBytes <= 0 || - maxResponseBytes > DEFAULT_MAX_RESPONSE_BYTES + request.headers !== undefined && + (!request.headers || typeof request.headers !== 'object' || Array.isArray(request.headers)) ) { - throw new Error('OCI response ceiling is outside the supported range') - } -} - -function sensitiveRequestValues( - credentials: OciSigningCredentials, - authorization: string | undefined, - requestUrl: string, - requestBody: string | undefined, - serviceHeaderValues: readonly string[] -): string[] { - return [ - credentials.tenancyId, - credentials.userId, - credentials.fingerprint, - credentials.fingerprint.toUpperCase(), - credentials.privateKey, - credentials.passphrase ?? '', - authorization ?? '', - requestUrl, - requestBody ?? '', - ...serviceHeaderValues, - ].filter(Boolean) -} - -function getSignedServiceHeaderValues( - serviceHeaders: Readonly> | undefined, - signedHeaders: Readonly> -): string[] { - return Object.keys(serviceHeaders ?? {}).flatMap((name) => { - const value = signedHeaders[name.toLowerCase()] - return value === undefined ? [] : [value] + throw new OciClientError('invalid_request') + } + const bodyMethod = BODY_METHODS.has(request.method) + if ( + (bodyMethod && (!(request.body instanceof Uint8Array) || request.contentType === undefined)) || + (!bodyMethod && (request.body !== undefined || request.contentType !== undefined)) + ) { + throw new OciClientError('invalid_request') + } + if ( + request.contentType !== undefined && + (typeof request.contentType !== 'string' || + request.contentType.length === 0 || + request.contentType.length > 256 || + CONTROL_PATTERN.test(request.contentType)) + ) { + throw new OciClientError('invalid_request') + } + const headers = validateHeaders(request.headers ?? {}) + if (request.queryPairs !== undefined && !Array.isArray(request.queryPairs)) { + throw new OciClientError('invalid_request') + } + const queryPairs = (request.queryPairs ?? []).map((pair) => { + if ( + !Array.isArray(pair) || + pair.length !== 2 || + typeof pair[0] !== 'string' || + typeof pair[1] !== 'string' + ) { + throw new OciClientError('invalid_request') + } + return Object.freeze([pair[0], pair[1]] as const) }) + let attempts = 1 + let retryToken: string | undefined + if (request.retry) { + if ( + typeof request.retry !== 'object' || + Array.isArray(request.retry) || + (request.retry.kind !== 'safe' && request.retry.kind !== 'tokenized') || + Object.keys(request.retry).some( + (key) => + key !== 'kind' && + key !== 'maxAttempts' && + !(request.retry?.kind === 'tokenized' && key === 'retryToken') + ) || + !Number.isSafeInteger(request.retry.maxAttempts) || + request.retry.maxAttempts < 2 || + request.retry.maxAttempts > MAX_ATTEMPTS + ) { + throw new OciClientError('invalid_request') + } + attempts = request.retry.maxAttempts + if (request.retry.kind === 'tokenized') { + if ( + typeof request.retry.retryToken !== 'string' || + request.retry.retryToken.length === 0 || + Buffer.byteLength(request.retry.retryToken, 'utf8') > MAX_RETRY_TOKEN_BYTES || + CONTROL_PATTERN.test(request.retry.retryToken) + ) { + throw new OciClientError('invalid_request') + } + retryToken = request.retry.retryToken + } + } + if (request.responseHeaders !== undefined && !Array.isArray(request.responseHeaders)) { + throw new OciClientError('invalid_request') + } + for (const name of request.responseHeaders ?? []) { + if (typeof name !== 'string' || !RESPONSE_HEADER_ALLOWLIST.has(name.toLowerCase())) { + throw new OciClientError('invalid_request') + } + } + return { + ...(request.body !== undefined ? { body: new Uint8Array(request.body) } : {}), + headers, + queryPairs, + attempts, + ...(retryToken !== undefined ? { retryToken } : {}), + } } -function isRedactableRequestMaterial(values: readonly (string | undefined)[]): boolean { - let totalLength = 0 - for (const value of values) { - if (value === undefined) continue - totalLength += value.length - if (totalLength > MAX_OCI_REDACTABLE_REQUEST_MATERIAL_LENGTH) return false +function signRequest(params: { + material: OciCredentialMaterial + method: OciRequestMethod + url: string + headers: Readonly> + body?: Uint8Array + contentType?: string + signingDate: Date +}): SignedOciRequest { + const url = new URL(params.url) + const headers: Record = { + ...params.headers, + host: url.host, + 'x-date': params.signingDate.toUTCString(), + } + const headerNames = ['x-date', '(request-target)', 'host'] + if (params.body !== undefined) { + headers['content-type'] = params.contentType! + headers['content-length'] = String(params.body.byteLength) + headers['x-content-sha256'] = createHash('sha256').update(params.body).digest('base64') + headerNames.push('content-type', 'content-length', 'x-content-sha256') + } + const target = `${url.pathname}${url.search}` + const signingString = headerNames + .map((name) => + name === '(request-target)' + ? `(request-target): ${params.method.toLowerCase()} ${target}` + : `${name}: ${headers[name]}` + ) + .join('\n') + const signature = createSign('RSA-SHA256') + .update(signingString) + .end() + .sign(params.material.privateKey, 'base64') + const keyId = `${params.material.tenancyOcid}/${params.material.userOcid}/${params.material.fingerprint}` + headers.authorization = `Signature version="1",keyId="${keyId}",algorithm="rsa-sha256",headers="${headerNames.join(' ')}",signature="${signature}"` + return { + url: params.url, + headers, + ...(params.body !== undefined ? { body: new Uint8Array(params.body) } : {}), + } +} + +function selectedResponseHeaders( + response: SecureFetchResponse, + requested: readonly string[] +): Readonly> { + const selected = new Set(['content-type', 'etag', 'opc-request-id', ...requested.map(String)]) + const result: Record = {} + for (const name of selected) { + const normalized = name.toLowerCase() + if (!RESPONSE_HEADER_ALLOWLIST.has(normalized)) continue + const value = response.headers.get(normalized) + if (value !== null) result[normalized] = value } - return true + return Object.freeze(result) } -async function readOciErrorBody( +async function readFailureCode( response: SecureFetchResponse, - method: OciRequestMethod, - maxResponseBytes: number, - signal: AbortSignal | undefined + signal: AbortSignal ): Promise { try { - return await readResponseTextWithLimit(response, { - maxBytes: Math.min(DEFAULT_MAX_ERROR_BODY_BYTES, maxResponseBytes), + const body = await readResponseToBufferWithLimit(response, { + maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, label: 'OCI error response', signal, allowNoBodyFallback: true, - requestMethod: method, }) - } catch (error) { - if (signal?.aborted) throw error + const parsed: unknown = JSON.parse(body.toString('utf8')) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined + const code = (parsed as Record).code + return typeof code === 'string' && code.length <= 128 ? code : undefined + } catch { await response.body?.cancel().catch(() => {}) return undefined } } -/** Sends one bounded, redirect-free OCI request to an already validated destination. */ -export async function sendOciRequest(params: { - destination: ValidatedOciDestination - credentials: OciSigningCredentials - method: OciRequestMethod - encodedPath: string - queryPairs?: readonly (readonly [string, string])[] - timeout: number - maxResponseBytes: number - signal?: AbortSignal - serviceHeaders?: Readonly> - body?: string - contentType?: string -}): Promise { - validateRequestLimits(params.timeout, params.maxResponseBytes) - const url = buildOciRequestUrl(params.destination, params.encodedPath, params.queryPairs) - const signed = await signOciRequest({ - credentials: params.credentials, - method: params.method, - url, - serviceHeaders: params.serviceHeaders, - body: params.body, - contentType: params.contentType, +function isRetryableTransportFailure(error: unknown): boolean { + if (!error || typeof error !== 'object') return false + const code = (error as { code?: unknown }).code + return typeof code === 'string' && RETRYABLE_TRANSPORT_CODES.has(code) +} + +function extractDiscoveredOrigin( + policy: OciDiscoveredEndpointPolicy, + snapshot: BoundResponseSnapshot +): string { + if (policy.source.kind === 'header') { + const value = snapshot.headers[policy.source.name] + if (!value) throw new OciClientError('invalid_endpoint') + return value + } + let value: unknown + try { + value = JSON.parse(Buffer.from(snapshot.body).toString('utf8')) + } catch { + throw new OciClientError('invalid_endpoint') + } + for (const segment of policy.source.path) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new OciClientError('invalid_endpoint') + } + value = (value as Record)[segment] + } + if (typeof value !== 'string') throw new OciClientError('invalid_endpoint') + return value +} + +function createDeadline( + timeoutMs: number, + callerSignal?: AbortSignal +): { + signal: AbortSignal + deadlineAt: number + expired: () => boolean + cleanup: () => void +} { + const controller = new AbortController() + let deadlineExpired = false + const deadlineAt = Date.now() + timeoutMs + const timer = setTimeout(() => { + deadlineExpired = true + controller.abort(new OciClientError('deadline_exceeded')) + }, timeoutMs) + const abortFromCaller = () => controller.abort(callerSignal?.reason) + if (callerSignal?.aborted) abortFromCaller() + else callerSignal?.addEventListener('abort', abortFromCaller, { once: true }) + return { + signal: controller.signal, + deadlineAt, + expired: () => deadlineExpired, + cleanup: () => { + clearTimeout(timer) + callerSignal?.removeEventListener('abort', abortFromCaller) + }, + } +} + +async function waitForRetry(delayMs: number, signal: AbortSignal): Promise { + if (signal.aborted) throw toError(signal.reason) + let rejectAbort: ((reason?: unknown) => void) | undefined + const aborted = new Promise((_, reject) => { + rejectAbort = reject }) - const response = await secureFetchWithValidation( - signed.url, - { - method: signed.method, - headers: { ...signed.headers }, - ...(signed.body !== undefined ? { body: signed.body } : {}), - timeout: params.timeout, - maxResponseBytes: params.maxResponseBytes, - maxRedirects: 0, - signal: params.signal, - profile: 'configuredEndpoint', - logUrlValidationDetails: false, + const onAbort = () => rejectAbort?.(signal.reason) + signal.addEventListener('abort', onAbort, { once: true }) + try { + await Promise.race([sleep(delayMs), aborted]) + } finally { + signal.removeEventListener('abort', onAbort) + } +} + +/** Creates a lazily loaded OCI client bound to trusted workspace and service context. */ +export async function createOciClient(params: CreateOciClientParams): Promise { + const service = getServiceConfigByServiceId(params.serviceId) + if (service?.serviceAccountProviderId !== OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID) { + throw new OciClientError('invalid_endpoint') + } + + let materialPromise: Promise | undefined + let lastSigningTime = 0 + const preparedEndpoints = new WeakSet() + const endpointPolicies = new WeakMap() + const responseSnapshots = new WeakMap() + + const getMaterial = () => { + materialPromise ??= loadCredentialMaterial({ + credentialId: params.credentialId, + workspaceId: params.workspaceId, + }) + return materialPromise + } + const assertPolicyOwner = (policy: OciEndpointPolicy) => { + if (policy.serviceId !== params.serviceId) throw new OciClientError('invalid_endpoint') + } + const effectiveRegion = async () => { + const material = await getMaterial() + return resolveEffectiveOciRegion(material.region, params.region) + } + const nextSigningDate = () => { + const now = Math.max(Date.now(), lastSigningTime + 1000) + lastSigningTime = now + return new Date(now) + } + + const client: OciClient = { + async prepareStaticEndpoint(policy) { + assertPolicyOwner(policy) + try { + const endpoint = resolveStaticOciEndpoint(policy, await effectiveRegion()) + preparedEndpoints.add(endpoint) + endpointPolicies.set(endpoint, policy) + return endpoint + } catch (error) { + if (error instanceof OciClientError) throw error + throw new OciClientError('invalid_endpoint') + } }, - 'OCI destination' - ) - const opcRequestId = response.headers.get('opc-request-id') ?? undefined - if (response.ok) return { response, opcRequestId } - - const serviceHeaderValues = getSignedServiceHeaderValues(params.serviceHeaders, signed.headers) - const requestMaterialIsRedactable = isRedactableRequestMaterial([ - signed.body, - ...serviceHeaderValues, - ]) - if (!requestMaterialIsRedactable) { - await response.body?.cancel().catch(() => {}) - throw new OciRequestError({ status: response.status }) - } - const sensitiveValues = sensitiveRequestValues( - params.credentials, - signed.headers.authorization, - signed.url, - signed.body, - serviceHeaderValues - ) - const body = await readOciErrorBody( - response, - signed.method, - params.maxResponseBytes, - params.signal - ) - const error = body === undefined ? {} : parseOciErrorBody(body, sensitiveValues) - throw new OciRequestError({ - status: response.status, - code: error.code, - message: error.message, - opcRequestId, - sensitiveValues, + + async prepareDiscoveredEndpoint(policy, response) { + assertPolicyOwner(policy) + const snapshot = responseSnapshots.get(response) + if (!snapshot || snapshot.policy !== policy.responsePolicy) { + throw new OciClientError('invalid_endpoint') + } + try { + const origin = extractDiscoveredOrigin(policy, snapshot) + const endpoint = resolveDiscoveredOciEndpoint(policy, snapshot.region, origin) + preparedEndpoints.add(endpoint) + endpointPolicies.set(endpoint, policy) + return endpoint + } catch (error) { + if (error instanceof OciClientError) throw error + throw new OciClientError('invalid_endpoint') + } + }, + + async request(request) { + if ( + !preparedEndpoints.has(request.endpoint) || + request.endpoint.serviceId !== params.serviceId + ) { + throw new OciClientError('invalid_endpoint') + } + const endpointPolicy = endpointPolicies.get(request.endpoint) + if (!endpointPolicy) throw new OciClientError('invalid_endpoint') + const validated = validateRequest(request) + const url = buildRequestUrl(request.endpoint, request.encodedPath, validated.queryPairs) + const deadline = createDeadline(request.timeoutMs, request.signal) + try { + const material = await getMaterial() + for (let attempt = 1; attempt <= validated.attempts; attempt += 1) { + if (deadline.signal.aborted) { + throw new OciClientError(deadline.expired() ? 'deadline_exceeded' : 'aborted') + } + const remainingMs = deadline.deadlineAt - Date.now() + if (remainingMs <= 0) throw new OciClientError('deadline_exceeded') + const signed = signRequest({ + material, + method: request.method, + url, + headers: { + ...validated.headers, + ...(validated.retryToken ? { 'opc-retry-token': validated.retryToken } : {}), + }, + body: validated.body, + contentType: request.contentType, + signingDate: nextSigningDate(), + }) + + let response: SecureFetchResponse + try { + response = await secureFetchWithValidation( + signed.url, + { + method: request.method, + headers: { ...signed.headers }, + ...(signed.body !== undefined ? { body: new Uint8Array(signed.body) } : {}), + timeout: Math.max(1, Math.floor(remainingMs)), + maxResponseBytes: request.maxResponseBytes, + maxRedirects: 0, + signal: deadline.signal, + profile: 'configuredEndpoint', + logUrlValidationDetails: false, + }, + 'OCI destination' + ) + } catch (error) { + if (deadline.signal.aborted) { + throw new OciClientError(deadline.expired() ? 'deadline_exceeded' : 'aborted') + } + if (attempt < validated.attempts && isRetryableTransportFailure(error)) { + const delay = backoffWithJitter(attempt, null, { baseMs: 200, maxMs: 5000 }) + if (delay >= deadline.deadlineAt - Date.now()) { + throw new OciClientError('deadline_exceeded') + } + await waitForRetry(delay, deadline.signal) + continue + } + throw new OciClientError('request_failed') + } + + const opcRequestId = response.headers.get('opc-request-id') + if (!response.ok) { + const providerCode = await readFailureCode(response, deadline.signal) + const retryable = + RETRYABLE_STATUSES.has(response.status) || + (response.status === 409 && providerCode === 'IncorrectState') + if (retryable && attempt < validated.attempts) { + const retryAfter = parseRetryAfter(response.headers.get('retry-after'), 5000) + const delay = backoffWithJitter(attempt, retryAfter, { baseMs: 200, maxMs: 5000 }) + if (delay >= deadline.deadlineAt - Date.now()) { + throw new OciClientError('deadline_exceeded') + } + await waitForRetry(delay, deadline.signal) + continue + } + throw new OciClientError('request_failed', { + status: response.status, + opcRequestId, + }) + } + + let body: Uint8Array + try { + const buffer = await readResponseToBufferWithLimit(response, { + maxBytes: request.maxResponseBytes, + label: 'OCI response', + signal: deadline.signal, + requestMethod: request.method, + allowNoBodyFallback: true, + }) + body = new Uint8Array(buffer) + } catch (error) { + if (deadline.signal.aborted) { + throw new OciClientError(deadline.expired() ? 'deadline_exceeded' : 'aborted') + } + if (isPayloadSizeLimitError(error)) throw new OciClientError('response_too_large') + throw new OciClientError('request_failed') + } + const headers = selectedResponseHeaders(response, request.responseHeaders ?? []) + const result = Object.freeze({ + status: response.status, + headers, + ...(opcRequestId ? { opcRequestId } : {}), + body: new Uint8Array(body), + }) as OciAuthenticatedResponse + responseSnapshots.set(result, { + status: response.status, + headers, + body: new Uint8Array(body), + region: request.endpoint.region, + policy: endpointPolicy, + }) + return result + } + throw new OciClientError('request_failed') + } catch (error) { + if (error instanceof OciClientError) throw error + if (deadline.signal.aborted) { + throw new OciClientError(deadline.expired() ? 'deadline_exceeded' : 'aborted') + } + throw new OciClientError('request_failed') + } finally { + deadline.cleanup() + } + }, + } + + return Object.freeze(client) +} + +/** @internal Performs only the fixed GetNamespace check used during credential setup. */ +export async function verifyOciApiKeyCredentialForSetup( + serializedSecret: string, + signal?: AbortSignal +): Promise { + const material = parseCredentialMaterial(serializedSecret) + const policy = createOciStaticEndpointPolicy({ + serviceId: OCI_SERVICE_ID, + serviceName: 'objectstorage', }) + const endpoint = resolveStaticOciEndpoint(policy, resolveEffectiveOciRegion(material.region)) + const url = buildRequestUrl(endpoint, '/n/', []) + const deadline = createDeadline(SETUP_VERIFICATION_TIMEOUT_MS, signal) + try { + const signed = signRequest({ + material, + method: 'GET', + url, + headers: { accept: 'application/json' }, + signingDate: new Date(), + }) + const response = await secureFetchWithValidation( + signed.url, + { + method: 'GET', + headers: { ...signed.headers }, + timeout: SETUP_VERIFICATION_TIMEOUT_MS, + maxResponseBytes: SETUP_VERIFICATION_RESPONSE_BYTES, + maxRedirects: 0, + signal: deadline.signal, + profile: 'configuredEndpoint', + logUrlValidationDetails: false, + }, + 'OCI credential verification destination' + ) + if (!response.ok) { + await readFailureCode(response, deadline.signal) + throw new OciClientError('request_failed', { + status: response.status, + opcRequestId: response.headers.get('opc-request-id'), + }) + } + const body = await readResponseToBufferWithLimit(response, { + maxBytes: SETUP_VERIFICATION_RESPONSE_BYTES, + label: 'OCI credential verification response', + signal: deadline.signal, + allowNoBodyFallback: true, + }) + return new Uint8Array(body) + } catch (error) { + if (error instanceof OciClientError) throw error + if (deadline.signal.aborted) { + throw new OciClientError(deadline.expired() ? 'deadline_exceeded' : 'aborted') + } + throw new OciClientError('request_failed') + } finally { + deadline.cleanup() + } } diff --git a/apps/sim/lib/internal/oci/endpoints.ts b/apps/sim/lib/internal/oci/endpoints.ts index 2e576890113..e9e0ddc5154 100644 --- a/apps/sim/lib/internal/oci/endpoints.ts +++ b/apps/sim/lib/internal/oci/endpoints.ts @@ -1,4 +1,5 @@ import { isIpLiteral, unwrapIpv6Brackets } from '@sim/security/ssrf' +import type { OAuthService } from '@/lib/oauth/types' export type OciDestinationProvenance = 'static' | 'authenticated-discovery' @@ -12,26 +13,43 @@ export interface OciRegion { readonly realm: OciRealm } -declare const validatedOciDestinationBrand: unique symbol +declare const preparedOciEndpointBrand: unique symbol -/** An OCI origin that passed both structural and service-owned hostname validation. */ -export interface ValidatedOciDestination { +/** An OCI endpoint prepared from a declarative product policy. */ +export interface OciPreparedEndpoint { readonly origin: string readonly hostname: string - readonly service: string + readonly serviceId: OAuthService + readonly serviceName: string readonly region: OciRegion readonly provenance: OciDestinationProvenance - readonly [validatedOciDestinationBrand]: true + readonly [preparedOciEndpointBrand]: true } -declare const ociServiceHostnamePredicateBrand: unique symbol +declare const ociEndpointPolicyBrand: unique symbol -export type OciServiceHostnamePredicate = ((params: { - hostname: string - service: string - region: OciRegion - provenance: OciDestinationProvenance -}) => boolean) & { readonly [ociServiceHostnamePredicateBrand]: true } +export interface OciStaticEndpointPolicy { + readonly kind: 'static' + readonly serviceId: OAuthService + readonly serviceName: string + readonly [ociEndpointPolicyBrand]: true +} + +export type OciDiscoverySource = + | { readonly kind: 'header'; readonly name: string } + | { readonly kind: 'json'; readonly path: readonly string[] } + +export interface OciDiscoveredEndpointPolicy { + readonly kind: 'authenticated-discovery' + readonly serviceId: OAuthService + readonly serviceName: string + readonly responsePolicy: OciEndpointPolicy + readonly source: OciDiscoverySource + readonly allowRegionalHost: boolean + readonly [ociEndpointPolicyBrand]: true +} + +export type OciEndpointPolicy = OciStaticEndpointPolicy | OciDiscoveredEndpointPolicy /** * Realm and region snapshot copied from `oci-common@2.140.0` files @@ -179,17 +197,83 @@ export function resolveEffectiveOciRegion(defaultRegion: string, override?: stri return effective } -export function objectStorageOciHostname(region: OciRegion): string { - return `objectstorage.${region.id}.${region.realm.domain}` +function assertServiceName(value: string): void { + if (!/^[a-z][a-z0-9-]{0,62}$/.test(value)) { + throw new Error('OCI endpoint policy service name is invalid') + } +} + +function assertDiscoverySource(source: OciDiscoverySource): void { + if (source.kind === 'header') { + if (!/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(source.name)) { + throw new Error('OCI discovery header name is invalid') + } + return + } + if ( + source.kind !== 'json' || + source.path.length === 0 || + source.path.length > 8 || + source.path.some( + (segment) => + segment.length === 0 || segment.length > 128 || /[\u0000-\u001f\u007f]/.test(segment) + ) + ) { + throw new Error('OCI discovery JSON path is invalid') + } +} + +/** Creates a frozen exact regional-host policy owned by one registered service. */ +export function createOciStaticEndpointPolicy(params: { + serviceId: OAuthService + serviceName: string +}): OciStaticEndpointPolicy { + assertServiceName(params.serviceName) + return Object.freeze({ + kind: 'static', + serviceId: params.serviceId, + serviceName: params.serviceName, + }) as OciStaticEndpointPolicy +} + +/** Creates a frozen authenticated-discovery policy without executable hostname callbacks. */ +export function createOciDiscoveredEndpointPolicy(params: { + serviceId: OAuthService + serviceName: string + responsePolicy: OciEndpointPolicy + source: OciDiscoverySource + allowRegionalHost?: boolean +}): OciDiscoveredEndpointPolicy { + assertServiceName(params.serviceName) + assertDiscoverySource(params.source) + if (params.responsePolicy.serviceId !== params.serviceId) { + throw new Error('OCI discovery source policy must have the same owning service') + } + const source = + params.source.kind === 'json' + ? Object.freeze({ ...params.source, path: Object.freeze([...params.source.path]) }) + : Object.freeze({ ...params.source, name: params.source.name.toLowerCase() }) + return Object.freeze({ + kind: 'authenticated-discovery', + serviceId: params.serviceId, + serviceName: params.serviceName, + responsePolicy: params.responsePolicy, + source, + allowRegionalHost: params.allowRegionalHost ?? false, + }) as OciDiscoveredEndpointPolicy +} + +export function regionalOciHostname(serviceName: string, region: OciRegion): string { + assertServiceName(serviceName) + return `${serviceName}.${region.id}.${region.realm.domain}` } -export function validateOciDestination(params: { +function validateOciOrigin(params: { origin: string - service: string + policy: OciEndpointPolicy region: OciRegion provenance: OciDestinationProvenance - isServiceHostname: OciServiceHostnamePredicate -}): ValidatedOciDestination { +}): OciPreparedEndpoint { const knownRegion = getOciRegion(params.region.id) if ( knownRegion.realm.id !== params.region.realm.id || @@ -204,8 +288,7 @@ export function validateOciDestination(params: { throw new Error('OCI destination must be a valid HTTPS origin') } if ( - (params.provenance !== 'static' && params.provenance !== 'authenticated-discovery') || - !/^[a-z][a-z0-9-]{0,62}$/.test(params.service) || + params.policy.kind !== params.provenance || url.protocol !== 'https:' || url.port !== '' || url.username !== '' || @@ -218,39 +301,51 @@ export function validateOciDestination(params: { ) { throw new Error('OCI destination must be an exact HTTPS origin with the default port') } - if ( - !params.isServiceHostname({ - hostname: url.hostname, - service: params.service, - region: knownRegion, - provenance: params.provenance, - }) - ) { + const regionalHostname = regionalOciHostname(params.policy.serviceName, knownRegion) + const hostnameMatches = + params.provenance === 'static' + ? url.hostname === regionalHostname + : url.hostname.endsWith(`.${regionalHostname}`) || + (params.policy.kind === 'authenticated-discovery' && + params.policy.allowRegionalHost && + url.hostname === regionalHostname) + if (!hostnameMatches) { throw new Error('OCI destination hostname is not owned by the requested service') } return { origin: url.origin, hostname: url.hostname, - service: params.service, + serviceId: params.policy.serviceId, + serviceName: params.policy.serviceName, region: knownRegion, provenance: params.provenance, - } as ValidatedOciDestination + } as OciPreparedEndpoint } -export const isObjectStorageOciHostname = (({ hostname, service, region }) => - service === 'objectstorage' && - hostname === objectStorageOciHostname(region)) as OciServiceHostnamePredicate +/** Resolves a static policy exclusively from its service and validated region. */ +export function resolveStaticOciEndpoint( + policy: OciStaticEndpointPolicy, + region: OciRegion +): OciPreparedEndpoint { + const hostname = regionalOciHostname(policy.serviceName, region) + return validateOciOrigin({ + origin: `https://${hostname}`, + policy, + region, + provenance: 'static', + }) +} -export function objectStorageOciDestination( +/** Structurally validates an origin extracted from an authenticated response. */ +export function resolveDiscoveredOciEndpoint( + policy: OciDiscoveredEndpointPolicy, region: OciRegion, - provenance: OciDestinationProvenance = 'static' -): ValidatedOciDestination { - const hostname = objectStorageOciHostname(region) - return validateOciDestination({ - origin: `https://${hostname}`, - service: 'objectstorage', + origin: string +): OciPreparedEndpoint { + return validateOciOrigin({ + origin, + policy, region, - provenance, - isServiceHostname: isObjectStorageOciHostname, + provenance: 'authenticated-discovery', }) } diff --git a/apps/sim/lib/internal/oci/errors.ts b/apps/sim/lib/internal/oci/errors.ts index 020e4b74afc..d4781e4d7cc 100644 --- a/apps/sim/lib/internal/oci/errors.ts +++ b/apps/sim/lib/internal/oci/errors.ts @@ -1,167 +1,52 @@ -import { - isSensitiveKey, - REDACTED_MARKER, - redactExactSensitiveValues, - redactKnownSensitiveValues, -} from '@/lib/core/security/redaction' - -const MAX_OCI_ERROR_FIELD_LENGTH = 1024 -const MAX_OCI_ERROR_INPUT_LENGTH = 65_536 -const MAX_NESTED_JSON_DEPTH = 3 -const OCI_SENSITIVE_JSON_FIELDS = new Set(['signature', 'signingstring']) - -function normalizeJsonDiagnosticKey(key: string): string | undefined { - let normalized = key - for (let depth = 0; depth < MAX_NESTED_JSON_DEPTH; depth += 1) { - if (!normalized.includes('%')) return normalized - if (!/%[0-9a-f]{2}/i.test(normalized)) return undefined - try { - normalized = decodeURIComponent(normalized) - } catch { - return undefined - } - } - return normalized.includes('%') ? undefined : normalized -} - -function looksLikeStructuredJson(value: string): boolean { - const first = value.trimStart()[0] - return first === '{' || first === '[' || first === '"' -} - -function isSensitiveOciJsonKey(key: string): boolean { - const compactKey = key.replace(/[^a-z]/gi, '').toLowerCase() - return OCI_SENSITIVE_JSON_FIELDS.has(compactKey) || isSensitiveKey(compactKey) -} - -function containsEmbeddedStructuredText(value: string): boolean { - return ( - !looksLikeStructuredJson(value) && - (/[[{]\s*\\*(?:["{[\]}]|-?\d|true\b|false\b|null\b)/.test(value) || - /\\*"[^"\\\r\n]{1,128}\\*"\s*:\s*/.test(value)) - ) -} - -function flattenJsonDiagnostic(value: unknown, depth = 0): string | undefined { - if (depth > MAX_NESTED_JSON_DEPTH) return undefined - if (value === null) return 'null' - if (typeof value === 'string') { - if (!looksLikeStructuredJson(value)) { - return containsEmbeddedStructuredText(value) ? undefined : value - } - if (depth === MAX_NESTED_JSON_DEPTH) return undefined - try { - return flattenJsonDiagnostic(JSON.parse(value), depth + 1) - } catch { - return undefined - } - } - if (typeof value === 'number' || typeof value === 'boolean') return String(value) - if (Array.isArray(value)) { - if (depth === MAX_NESTED_JSON_DEPTH) return undefined - const flattened = value.map((entry) => flattenJsonDiagnostic(entry, depth + 1)) - if (flattened.some((entry) => entry === undefined)) return undefined - return flattened.join(' ') - } - if (typeof value !== 'object') return undefined - if (depth === MAX_NESTED_JSON_DEPTH) return undefined - const flattened = Object.entries(value).map(([key, entry]) => { - const normalizedKey = normalizeJsonDiagnosticKey(key) - if (normalizedKey === undefined) return undefined - if (isSensitiveOciJsonKey(normalizedKey)) return `${key}: ${REDACTED_MARKER}` - const nested = flattenJsonDiagnostic(entry, depth + 1) - return nested === undefined ? undefined : `${key}: ${nested}` - }) - if (flattened.some((entry) => entry === undefined)) return undefined - return flattened.join(' ') -} - -function decodeNestedJsonDiagnostic(value: string): string | undefined { - if (value.length > MAX_OCI_ERROR_INPUT_LENGTH) return undefined - if (containsEmbeddedStructuredText(value)) return undefined - if (!looksLikeStructuredJson(value)) return value - try { - return flattenJsonDiagnostic(JSON.parse(value)) - } catch { +export type OciClientErrorCode = + | 'credential_unavailable' + | 'invalid_request' + | 'invalid_endpoint' + | 'deadline_exceeded' + | 'aborted' + | 'response_too_large' + | 'request_failed' + +const ERROR_MESSAGES: Record = { + credential_unavailable: 'OCI credential is unavailable', + invalid_request: 'OCI request is invalid', + invalid_endpoint: 'OCI endpoint is invalid', + deadline_exceeded: 'OCI request deadline exceeded', + aborted: 'OCI request was canceled', + response_too_large: 'OCI response exceeded the configured limit', + request_failed: 'OCI request failed', +} + +function safeRequestId(value: unknown): string | undefined { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > 255 || + /[^\x20-\x7e]/.test(value) + ) { return undefined } + return value } -function sanitizeOciErrorField( - value: unknown, - sensitiveValues: readonly string[] = [] -): string | undefined { - if (typeof value !== 'string') return undefined - if (value.length > MAX_OCI_ERROR_INPUT_LENGTH) return undefined - const exactValues = sensitiveValues.flatMap((sensitiveValue) => { - const jsonEncoded = JSON.stringify(sensitiveValue).slice(1, -1) - return jsonEncoded === sensitiveValue ? [sensitiveValue] : [sensitiveValue, jsonEncoded] - }) - let knownRedacted: string - try { - knownRedacted = redactKnownSensitiveValues(value, exactValues) - } catch { - return undefined - } - if (/%[0-9a-f]{2}/i.test(knownRedacted)) return undefined - if (/\\(?:u[0-9a-f]{4}|x[0-9a-f]{2})/i.test(knownRedacted)) return undefined - if (/\(request-target\)|x-content-sha256/i.test(knownRedacted)) return undefined - if (/\bsignature\s*(?:version\s*)?=/i.test(knownRedacted)) return undefined - const decoded = decodeNestedJsonDiagnostic(knownRedacted) - if (decoded === undefined) return undefined - let exactRedacted: string - try { - exactRedacted = redactExactSensitiveValues(decoded, exactValues) - } catch { - return undefined - } - const sanitized = exactRedacted - .replace(/-----BEGIN[\s\S]*/gi, '[redacted-key]') - .replace(/https?:\/\/[^\s"']+/gi, '[redacted-url]') - .replace(/Signature\s+version\s*=\s*\\*"1\\*"\s*,[^\r\n]*/gi, '[redacted-authorization]') - .replace(/[\u0000-\u001f\u007f]/g, ' ') - .trim() - return sanitized ? sanitized.slice(0, MAX_OCI_ERROR_FIELD_LENGTH) : undefined -} - -/** A bounded, credential-safe projection of an OCI service error. */ -export class OciRequestError extends Error { - readonly status: number - readonly code?: string +/** Stable, provider-message-free failure projected by the native OCI client. */ +export class OciClientError extends Error { + readonly code: OciClientErrorCode + readonly status?: number readonly opcRequestId?: string - constructor(params: { - status: number - code?: unknown - message?: unknown - opcRequestId?: unknown - sensitiveValues?: readonly string[] - }) { - const code = sanitizeOciErrorField(params.code, params.sensitiveValues) - const message = sanitizeOciErrorField(params.message, params.sensitiveValues) - super( - message ? `OCI request failed: ${message}` : `OCI request failed with status ${params.status}` - ) - this.name = 'OciRequestError' - this.status = params.status + constructor(code: OciClientErrorCode, options: { status?: number; opcRequestId?: unknown } = {}) { + super(ERROR_MESSAGES[code]) + this.name = 'OciClientError' this.code = code - this.opcRequestId = sanitizeOciErrorField(params.opcRequestId, params.sensitiveValues) - } -} - -export function parseOciErrorBody( - body: string, - sensitiveValues: readonly string[] = [] -): { code?: string; message?: string } { - try { - const parsed: unknown = JSON.parse(body) - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {} - const record = parsed as Record - return { - code: sanitizeOciErrorField(record.code, sensitiveValues), - message: sanitizeOciErrorField(record.message, sensitiveValues), + if ( + options.status !== undefined && + Number.isInteger(options.status) && + options.status >= 100 && + options.status <= 599 + ) { + this.status = options.status } - } catch { - return {} + this.opcRequestId = safeRequestId(options.opcRequestId) } } diff --git a/apps/sim/lib/internal/oci/signing.server.test.ts b/apps/sim/lib/internal/oci/signing.server.test.ts deleted file mode 100644 index b8abd179775..00000000000 --- a/apps/sim/lib/internal/oci/signing.server.test.ts +++ /dev/null @@ -1,225 +0,0 @@ -/** - * @vitest-environment node - */ -import { createHash, createPublicKey, createVerify, generateKeyPairSync } from 'node:crypto' -import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' -import { - type OciRequestMethod, - type OciSigningCredentials, - signOciRequest, -} from '@/lib/internal/oci/signing.server' - -/** Oracle's public request-signing fixture from the OCI Request Signatures documentation. */ -const ORACLE_FIXTURE_PRIVATE_KEY = `${['-----BEGIN', 'RSA PRIVATE KEY-----'].join(' ')} -MIICXgIBAAKBgQDCFENGw33yGihy92pDjZQhl0C36rPJj+CvfSC8+q28hxA161QF -NUd13wuCTUcq0Qd2qsBe/2hFyc2DCJJg0h1L78+6Z4UMR7EOcpfdUE9Hf3m/hs+F -UR45uBJeDK1HSFHD8bHKD6kv8FPGfJTotc+2xjJwoYi+1hqp1fIekaxsyQIDAQAB -AoGBAJR8ZkCUvx5kzv+utdl7T5MnordT1TvoXXJGXK7ZZ+UuvMNUCdN2QPc4sBiA -QWvLw1cSKt5DsKZ8UETpYPy8pPYnnDEz2dDYiaew9+xEpubyeW2oH4Zx71wqBtOK -kqwrXa/pzdpiucRRjk6vE6YY7EBBs/g7uanVpGibOVAEsqH1AkEA7DkjVH28WDUg -f1nqvfn2Kj6CT7nIcE3jGJsZZ7zlZmBmHFDONMLUrXR/Zm3pR5m0tCmBqa5RK95u -412jt1dPIwJBANJT3v8pnkth48bQo/fKel6uEYyboRtA5/uHuHkZ6FQF7OUkGogc -mSJluOdc5t6hI1VsLn0QZEjQZMEOWr+wKSMCQQCC4kXJEsHAve77oP6HtG/IiEn7 -kpyUXRNvFsDE0czpJJBvL/aRFUJxuRK91jhjC68sA7NsKMGg5OXb5I5Jj36xAkEA -gIT7aFOYBFwGgQAQkWNKLvySgKbAZRTeLBacpHMuQdl1DfdntvAyqpAZ0lY0RKmW -G6aFKaqQfOXKCyWoUiVknQJAXrlgySFci/2ueKlIE1QqIiLSZ8V8OlpFLRnb1pzI -7U1yQXnTAEFYM560yJlzUpOb1V4cScGd365tiSMvxLOvTA== -${['-----END', 'RSA PRIVATE KEY-----'].join(' ')}` - -const BASE_CREDENTIALS: OciSigningCredentials = { - tenancyId: 'ocid1.tenancy.oc1..oraclefixture', - userId: 'ocid1.user.oc1..oraclefixture', - fingerprint: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff', - privateKey: ORACLE_FIXTURE_PRIVATE_KEY, -} - -function authorizationParameter(authorization: string, name: string): string { - const match = new RegExp(`${name}="([^"]+)"`).exec(authorization) - if (!match?.[1]) throw new Error(`Missing ${name} authorization parameter`) - return match[1] -} - -function expectValidSignature(params: { - request: Awaited> - publicKey: ReturnType -}): void { - const authorization = params.request.headers.authorization - expect(authorization).toBeDefined() - const headerNames = authorizationParameter(authorization!, 'headers').split(' ') - const url = new URL(params.request.url) - const signingString = headerNames - .map((name) => { - if (name === '(request-target)') { - return `(request-target): ${params.request.method.toLowerCase()} ${url.pathname}${url.search}` - } - const value = params.request.headers[name.toLowerCase()] - if (value === undefined) throw new Error(`Signed header ${name} is absent`) - return `${name.toLowerCase()}: ${value}` - }) - .join('\n') - const signature = authorizationParameter(authorization!, 'signature') - const verifier = createVerify('RSA-SHA256').update(signingString).end() - expect(verifier.verify(params.publicKey, signature, 'base64')).toBe(true) -} - -describe('signOciRequest', () => { - let generatedCredentials: OciSigningCredentials - let encryptedCredentials: OciSigningCredentials - let generatedPublicKey: ReturnType - - beforeAll(() => { - const pair = generateKeyPairSync('rsa', { modulusLength: 2048 }) - const privateKey = pair.privateKey.export({ format: 'pem', type: 'pkcs8' }).toString() - generatedPublicKey = createPublicKey(pair.privateKey) - generatedCredentials = { ...BASE_CREDENTIALS, privateKey } - encryptedCredentials = { - ...BASE_CREDENTIALS, - privateKey: pair.privateKey - .export({ - format: 'pem', - type: 'pkcs8', - cipher: 'aes-256-cbc', - passphrase: 'signing-test-passphrase', - }) - .toString(), - passphrase: 'signing-test-passphrase', - } - }) - - afterEach(() => { - vi.useRealTimers() - }) - - it('signs Oracle’s published RSA fixture entirely in memory', async () => { - vi.useFakeTimers() - vi.setSystemTime(new Date('2026-09-03T19:00:00.000Z')) - const request = await signOciRequest({ - credentials: BASE_CREDENTIALS, - method: 'GET', - url: 'https://iaas.us-phoenix-1.oraclecloud.com/20160918/instances?displayName=Team%20X', - }) - expectValidSignature({ request, publicKey: createPublicKey(ORACLE_FIXTURE_PRIVATE_KEY) }) - expect(request.headers.authorization).toContain( - `keyId="${BASE_CREDENTIALS.tenancyId}/${BASE_CREDENTIALS.userId}/${BASE_CREDENTIALS.fingerprint}"` - ) - }) - - it('signs with an independently generated encrypted PKCS#8 key', async () => { - const request = await signOciRequest({ - credentials: encryptedCredentials, - method: 'GET', - url: 'https://identity.us-ashburn-1.oraclecloud.com/20160918/users', - }) - expectValidSignature({ request, publicKey: generatedPublicKey }) - }) - - it.each(['GET', 'HEAD', 'DELETE'] as const)('signs %s without body headers', async (method) => { - const request = await signOciRequest({ - credentials: generatedCredentials, - method, - url: 'https://identity.us-ashburn-1.oraclecloud.com/20160918/users?a=1&a=&name=%E2%98%83', - serviceHeaders: { accept: 'application/json' }, - }) - expect(request.body).toBeUndefined() - expect(request.headers['content-length']).toBeUndefined() - expect(request.headers['x-content-sha256']).toBeUndefined() - expect(request.headers.date).toBeUndefined() - expectValidSignature({ request, publicKey: generatedPublicKey }) - }) - - it.each(['POST', 'PUT', 'PATCH'] as const)( - 'signs empty and Unicode %s bodies with byte-correct headers', - async (method) => { - for (const body of ['', '{"message":"héllo ☃"}']) { - const request = await signOciRequest({ - credentials: generatedCredentials, - method, - url: 'https://identity.us-ashburn-1.oraclecloud.com/20160918/users', - body, - }) - expect(request.body).toBe(body) - expect(request.headers['content-length']).toBe(String(Buffer.byteLength(body, 'utf8'))) - expect(request.headers['x-content-sha256']).toBe( - createHash('sha256').update(body, 'utf8').digest('base64') - ) - expect(request.headers['content-type']).toBe('application/json') - expect(request.headers.date).toBeUndefined() - expectValidSignature({ request, publicKey: generatedPublicKey }) - } - } - ) - - it('preserves finalized URL/query bytes in the signed request target', async () => { - const url = - 'https://identity.us-ashburn-1.oraclecloud.com/resource?z=last&a=one&a=&unicode=%E2%98%83' - const request = await signOciRequest({ credentials: generatedCredentials, method: 'GET', url }) - expect(request.url).toBe(url) - expectValidSignature({ request, publicKey: generatedPublicKey }) - }) - - it('creates a fresh x-date and removes the signer’s unsigned date header', async () => { - vi.useFakeTimers() - vi.setSystemTime(new Date('2026-09-03T19:00:00.000Z')) - const first = await signOciRequest({ - credentials: generatedCredentials, - method: 'GET', - url: 'https://identity.us-ashburn-1.oraclecloud.com/a', - }) - vi.setSystemTime(new Date('2026-09-03T19:00:01.000Z')) - const second = await signOciRequest({ - credentials: generatedCredentials, - method: 'GET', - url: 'https://identity.us-ashburn-1.oraclecloud.com/a', - }) - expect(first.headers['x-date']).not.toBe(second.headers['x-date']) - expect(first.headers.date).toBeUndefined() - expect(second.headers.date).toBeUndefined() - }) - - it.each(['GET', 'HEAD', 'DELETE'] as OciRequestMethod[])( - 'rejects a body on %s', - async (method) => { - await expect( - signOciRequest({ - credentials: generatedCredentials, - method, - url: 'https://identity.us-ashburn-1.oraclecloud.com/a', - body: '', - }) - ).rejects.toThrow('must not include a body') - } - ) - - it.each([Buffer.from('body'), new Uint8Array([1, 2, 3])])( - 'rejects non-string request bodies', - async (body) => { - await expect( - signOciRequest({ - credentials: generatedCredentials, - method: 'POST', - url: 'https://identity.us-ashburn-1.oraclecloud.com/a', - body: body as unknown as string, - }) - ).rejects.toThrow('finalized strings') - } - ) - - it.each([ - 'Authorization', - 'HOST', - 'date', - 'x-date', - 'content-length', - 'content-type', - 'x-content-sha256', - ])('blocks callers from overriding %s', async (header) => { - await expect( - signOciRequest({ - credentials: generatedCredentials, - method: 'GET', - url: 'https://identity.us-ashburn-1.oraclecloud.com/a', - serviceHeaders: { [header]: 'attacker-controlled' }, - }) - ).rejects.toThrow('signing-controlled') - }) -}) diff --git a/apps/sim/lib/internal/oci/signing.server.ts b/apps/sim/lib/internal/oci/signing.server.ts deleted file mode 100644 index 7382c87ed36..00000000000 --- a/apps/sim/lib/internal/oci/signing.server.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { DefaultRequestSigner, SimpleAuthenticationDetailsProvider } from 'oci-common' - -export type OciRequestMethod = 'GET' | 'HEAD' | 'DELETE' | 'POST' | 'PUT' | 'PATCH' - -export interface OciSigningCredentials { - readonly tenancyId: string - readonly userId: string - readonly fingerprint: string - readonly privateKey: string - readonly passphrase?: string -} - -export interface SignedOciRequest { - readonly method: OciRequestMethod - readonly url: string - readonly headers: Readonly> - readonly body?: string -} - -const BODY_METHODS: ReadonlySet = new Set(['POST', 'PUT', 'PATCH']) - -export const OCI_SIGNING_CONTROLLED_HEADERS: ReadonlySet = new Set([ - 'authorization', - 'host', - 'date', - 'x-date', - 'content-length', - 'content-type', - 'x-content-sha256', -]) - -function assertServiceHeaders(headers: Readonly>): void { - for (const [name, value] of Object.entries(headers)) { - if (OCI_SIGNING_CONTROLLED_HEADERS.has(name.toLowerCase())) { - throw new Error(`OCI service header is signing-controlled: ${name}`) - } - if ( - typeof value !== 'string' || - !/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(name) || - /[\u0000-\u001f\u007f]/.test(value) - ) { - throw new Error('OCI service headers must not contain control characters') - } - } -} - -/** Signs one finalized OCI request without consulting local OCI configuration. */ -export async function signOciRequest(params: { - credentials: OciSigningCredentials - method: OciRequestMethod - url: string - serviceHeaders?: Readonly> - body?: string - contentType?: string -}): Promise { - const serviceHeaders = params.serviceHeaders ?? {} - assertServiceHeaders(serviceHeaders) - const hasBodyMethod = BODY_METHODS.has(params.method) - if (!hasBodyMethod && params.body !== undefined) { - throw new Error(`${params.method} requests must not include a body`) - } - if (params.body !== undefined && typeof params.body !== 'string') { - throw new Error('OCI request bodies must be finalized strings') - } - if (params.contentType !== undefined && !hasBodyMethod) { - throw new Error('OCI content type is only valid for requests with signed bodies') - } - if ( - params.contentType !== undefined && - (params.contentType.length === 0 || - params.contentType.length > 256 || - /[\u0000-\u001f\u007f]/.test(params.contentType)) - ) { - throw new Error('OCI content type must not contain control characters') - } - - const body = hasBodyMethod ? (params.body ?? '') : undefined - const headers = new Headers(serviceHeaders) - headers.set('x-date', new Date().toUTCString()) - if (hasBodyMethod) headers.set('content-type', params.contentType ?? 'application/json') - - const provider = new SimpleAuthenticationDetailsProvider( - params.credentials.tenancyId, - params.credentials.userId, - params.credentials.fingerprint, - params.credentials.privateKey, - params.credentials.passphrase ?? null - ) - const signer = new DefaultRequestSigner(provider) - await signer.signHttpRequest({ - method: params.method, - uri: params.url, - headers, - ...(body !== undefined ? { body } : {}), - }) - headers.delete('date') - - return { - method: params.method, - url: params.url, - headers: Object.fromEntries(headers.entries()), - ...(body !== undefined ? { body } : {}), - } -} diff --git a/apps/sim/lib/oauth/credential-service.ts b/apps/sim/lib/oauth/credential-service.ts index 97a9747c4e2..9c133d92556 100644 --- a/apps/sim/lib/oauth/credential-service.ts +++ b/apps/sim/lib/oauth/credential-service.ts @@ -46,6 +46,7 @@ import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE, GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID, + OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID, SLACK_CUSTOM_BOT_PROVIDER_ID, } from '@/lib/oauth/types' @@ -631,6 +632,9 @@ type ServiceAccountTokenResolver = ( * generically: the stored token IS the access token. */ const SERVICE_ACCOUNT_TOKEN_RESOLVERS: Record = { + [OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID]: async (credentialId) => ({ + accessToken: credentialId, + }), [ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID]: async (credentialId) => { const secret = await getAtlassianServiceAccountSecret(credentialId) return { accessToken: secret.apiToken, cloudId: secret.cloudId, domain: secret.domain } diff --git a/apps/sim/lib/oauth/token-resolution.ts b/apps/sim/lib/oauth/token-resolution.ts index f198d900626..755a48ecd43 100644 --- a/apps/sim/lib/oauth/token-resolution.ts +++ b/apps/sim/lib/oauth/token-resolution.ts @@ -26,7 +26,8 @@ import { } from '@/lib/oauth/microsoft-dataverse' import { parseQuickBooksAccountId } from '@/lib/oauth/quickbooks' import { extractSalesforceInstanceUrl, isSalesforceOAuthProviderId } from '@/lib/oauth/salesforce' -import { getCanonicalScopesForProvider } from '@/lib/oauth/utils' +import { type OAuthService, OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types' +import { getCanonicalScopesForProvider, getServiceConfigByServiceId } from '@/lib/oauth/utils' import { captureServerEvent } from '@/lib/posthog/server' import { getToolMetadata } from '@/tools/metadata' import { extractZohoDeskBaseFromScope } from '@/tools/zoho_desk/host-allowlist' @@ -61,6 +62,8 @@ export interface ResolveCredentialTokenInput { auditRequest?: CredentialAuditRequest /** Credential lookup already performed by {@link resolveCredentialAccessToken}'s dispatch. */ resolvedCredential: ResolvedCredential | null + /** Trusted provider binding derived from registered tool metadata. */ + expectedServiceAccountProviderId?: string } export type ResolveCredentialTokenResult = @@ -262,13 +265,27 @@ export async function resolveCredentialToken( return { ok: false, status: 403, error: authz.error || 'Unauthorized' } } + const authoritativeId = authz.resolvedCredentialId + if (!authoritativeId) return { ok: false, status: 403, error: 'Unauthorized' } + const authoritative = await resolveOAuthAccountId(authoritativeId) + if ( + authoritative?.credentialType !== 'service_account' || + authoritative.credentialId !== authoritativeId || + (authoritative.providerId === OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID && + input.expectedServiceAccountProviderId !== OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID) || + (input.expectedServiceAccountProviderId !== undefined && + authoritative.providerId !== input.expectedServiceAccountProviderId) + ) { + return { ok: false, status: 403, error: 'Unauthorized' } + } + const saActorId = authz.requesterUserId - const saWorkspaceId = resolved.workspaceId ?? authz.workspaceId ?? null + const saWorkspaceId = authz.workspaceId ?? null try { const result = await resolveServiceAccountToken( - resolved.credentialId, - resolved.providerId, + authoritativeId, + authoritative.providerId, scopes ?? [], impersonateEmail ) @@ -277,8 +294,8 @@ export async function resolveCredentialToken( recordCredentialAccess({ actorId: saActorId, workspaceId: saWorkspaceId, - resourceId: resolved.credentialId, - providerId: resolved.providerId, + resourceId: authoritativeId, + providerId: authoritative.providerId, credentialType: 'service_account', auditRequest, }) @@ -396,6 +413,34 @@ export async function resolveCredentialAccessToken( const resolved = credentialId ? await resolveOAuthAccountId(credentialId) : null if (resolved?.credentialType !== 'managed_oauth' || !resolved.credentialId) { + const toolMetadata = toolId ? getToolMetadata(toolId) : undefined + const serviceId = toolMetadata?.oauth?.provider as OAuthService | undefined + const service = serviceId ? getServiceConfigByServiceId(serviceId) : null + const isOciServiceAccountTool = + toolMetadata?.oauth?.required === true && + toolMetadata.oauth.credentialKind === 'service-account' && + service?.serviceAccountProviderId === OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID + const expectedServiceAccountProviderId = isOciServiceAccountTool + ? OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID + : undefined + + if ( + resolved?.credentialType === 'service_account' && + resolved.providerId === OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID && + !isOciServiceAccountTool + ) { + logger.error(`[${requestId}] Tool is not configured for OCI API-key credentials`, { + toolId, + serviceId, + }) + return { + ok: false, + status: 500, + code: 'OCI_CREDENTIAL_TOOL_UNSUPPORTED', + error: 'This tool is not configured to use OCI API-key credentials', + } + } + const auth = await input.authenticate() return resolveCredentialToken(auth, { requestId, @@ -411,6 +456,7 @@ export async function resolveCredentialAccessToken( callerUserId: input.callerUserId, auditRequest, resolvedCredential: resolved, + expectedServiceAccountProviderId, }) } diff --git a/apps/sim/lib/oauth/types.ts b/apps/sim/lib/oauth/types.ts index ce8e727a2de..a8485f58c62 100644 --- a/apps/sim/lib/oauth/types.ts +++ b/apps/sim/lib/oauth/types.ts @@ -20,6 +20,9 @@ export const OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID = 'oci-api-key-service-acco /** Discriminator stored inside the encrypted OCI API signing-key secret blob. */ export const OCI_API_KEY_SERVICE_ACCOUNT_SECRET_TYPE = 'oci_api_signing_key_v1' as const +/** Registered credential-family owner for OCI API-key credentials. */ +export const OCI_SERVICE_ID = 'oci' as const satisfies OAuthService + /** * Discriminator stored inside the encrypted Atlassian service account secret blob. */ @@ -100,6 +103,7 @@ export type OAuthProvider = | 'zoho-desk' export type OAuthService = + | 'oci' | 'google' | 'google-email' | 'google-drive' diff --git a/apps/sim/lib/selectors/server/credentials.ts b/apps/sim/lib/selectors/server/credentials.ts index 2bc68c62275..8dfad516583 100644 --- a/apps/sim/lib/selectors/server/credentials.ts +++ b/apps/sim/lib/selectors/server/credentials.ts @@ -130,13 +130,13 @@ export async function authorizeSelectorCredential(input: { ...(input.scope.kind === 'workspace' ? { workspaceId: input.workspaceId } : {}), } ) - if (!access.ok || access.workspaceId !== input.workspaceId) { + if (!access.ok || access.workspaceId !== input.workspaceId || !access.resolvedCredentialId) { throw new SelectorConnectionUnavailableError() } input.protectedValues.add(access.resolvedCredentialId, 'reference') const providerId = await requireCredentialProviderBinding( - suppliedId, + access.resolvedCredentialId, access, input.policy.serviceIds ) diff --git a/apps/sim/package.json b/apps/sim/package.json index 2355ccaf47a..89753ba2f90 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -210,7 +210,6 @@ "next-themes": "^0.4.6", "nodemailer": "9.0.1", "nuqs": "2.8.9", - "oci-common": "2.140.0", "officeparser": "5.2.2", "openai": "7.0.0", "opentype.js": "1.3.4", diff --git a/bun.lock b/bun.lock index c455c3b02e3..134649eafe6 100644 --- a/bun.lock +++ b/bun.lock @@ -320,7 +320,6 @@ "next-themes": "^0.4.6", "nodemailer": "9.0.1", "nuqs": "2.8.9", - "oci-common": "2.140.0", "officeparser": "5.2.2", "openai": "7.0.0", "opentype.js": "1.3.4", @@ -2228,16 +2227,12 @@ "@types/http-cache-semantics": ["@types/http-cache-semantics@4.2.0", "", {}, "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q=="], - "@types/isomorphic-fetch": ["@types/isomorphic-fetch@0.0.35", "", {}, "sha512-DaZNUvLDCAnCTjgwxgiL1eQdxIKEpNLOlTNtAgnZc50bG2copGhRrFN9/PxPBuJe+tZVLCbQ7ls0xveXVRPkvw=="], - "@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="], "@types/jsdom": ["@types/jsdom@21.1.7", "", { "dependencies": { "@types/node": "*", "@types/tough-cookie": "*", "parse5": "^7.0.0" } }, "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA=="], "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], - "@types/jsonwebtoken": ["@types/jsonwebtoken@9.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-b0jGiOgHtZ2jqdPgPnP6WLCXZk1T8p06A/vPGzUvxpFGgKMbjXJDjC5m52ErqBnIuWZFgGoIJyRdeG5AyreJjA=="], - "@types/keyv": ["@types/keyv@3.1.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg=="], "@types/lodash": ["@types/lodash@4.17.24", "", {}, "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ=="], @@ -2260,8 +2255,6 @@ "@types/opentype.js": ["@types/opentype.js@1.3.10", "", {}, "sha512-F67EFyk6j02okHz5JCgata3ZRAcZi9GLnzmkHw/rzJq3OCc8/ZVdoKrxMTYjcQP6IYHGBz2cav1cpzkOkPiPCQ=="], - "@types/opossum": ["@types/opossum@4.1.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-9TMnd8AWRVtnZMqBbbzceQoJdafErgUViogFaQ3eetsbeLtiFFZ695mepNaLtlfJi4uRP3GmHfe3CJ2DZKaxYA=="], - "@types/pako": ["@types/pako@1.0.7", "", {}, "sha512-YBtzT2ztNF6R/9+UXj2wTGFnC9NklAnASt3sC0h2m1bbH7G6FyBIkt4AN8ThZpNfxUo1b2iMVO0UawiJymEt8A=="], "@types/prismjs": ["@types/prismjs@1.26.6", "", {}, "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw=="], @@ -2282,8 +2275,6 @@ "@types/ssh2": ["@types/ssh2@1.15.5", "", { "dependencies": { "@types/node": "^18.11.18" } }, "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ=="], - "@types/sshpk": ["@types/sshpk@1.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-cru1waDhHZnZuB18E6Dgf2UXf8U93mdOEDcKYe5jTri+fpucidSs7DLmGICpLxN+95aYkwtgeyny9fBFzQVdmA=="], - "@types/tough-cookie": ["@types/tough-cookie@4.0.5", "", {}, "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA=="], "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], @@ -2292,8 +2283,6 @@ "@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="], - "@types/uuid": ["@types/uuid@8.3.4", "", {}, "sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw=="], - "@types/webidl-conversions": ["@types/webidl-conversions@7.0.3", "", {}, "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA=="], "@types/whatwg-url": ["@types/whatwg-url@11.0.5", "", { "dependencies": { "@types/webidl-conversions": "*" } }, "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ=="], @@ -2456,8 +2445,6 @@ "asn1js": ["asn1js@3.0.10", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.5", "tslib": "^2.8.1" } }, "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg=="], - "assert-plus": ["assert-plus@1.0.0", "", {}, "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw=="], - "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], "ast-v8-to-istanbul": ["ast-v8-to-istanbul@1.0.4", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA=="], @@ -2818,8 +2805,6 @@ "dagre-d3-es": ["dagre-d3-es@7.0.14", "", { "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" } }, "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg=="], - "dashdash": ["dashdash@1.14.1", "", { "dependencies": { "assert-plus": "^1.0.0" } }, "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g=="], - "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], "data-urls": ["data-urls@5.0.0", "", { "dependencies": { "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.0.0" } }, "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg=="], @@ -2944,8 +2929,6 @@ "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], - "ecc-jsbn": ["ecc-jsbn@0.1.2", "", { "dependencies": { "jsbn": "~0.1.0", "safer-buffer": "^2.1.0" } }, "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw=="], - "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], "echarts": ["echarts@6.1.0", "", { "dependencies": { "tslib": "2.3.0", "zrender": "6.1.0" } }, "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA=="], @@ -3012,8 +2995,6 @@ "es6-error": ["es6-error@4.1.1", "", {}, "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg=="], - "es6-promise": ["es6-promise@4.2.6", "", {}, "sha512-aRVgGdnmW2OiySVPUC9e6m+plolMAJKjZnQlCwNSuK5yQ0JN61DZSO1X1Ufd1foqWRAlig0rhduTCHe7sVtK5Q=="], - "esast-util-from-estree": ["esast-util-from-estree@2.0.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "devlop": "^1.0.0", "estree-util-visit": "^2.0.0", "unist-util-position-from-estree": "^2.0.0" } }, "sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ=="], "esast-util-from-js": ["esast-util-from-js@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "acorn": "^8.0.0", "esast-util-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw=="], @@ -3082,8 +3063,6 @@ "extend-shallow": ["extend-shallow@2.0.1", "", { "dependencies": { "is-extendable": "^0.1.0" } }, "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug=="], - "extsprintf": ["extsprintf@1.3.0", "", {}, "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g=="], - "fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="], "fast-content-type-parse": ["fast-content-type-parse@2.0.1", "", {}, "sha512-nGqtvLrj5w0naR6tDPfB4cUmYCqouzyQiz6C5y/LtcDllJdrcc6WaWW6iXyIIOErTa/XRybj28aasdn4LkVk6Q=="], @@ -3198,8 +3177,6 @@ "get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="], - "getpass": ["getpass@0.1.7", "", { "dependencies": { "assert-plus": "^1.0.0" } }, "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng=="], - "giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="], "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], @@ -3312,8 +3289,6 @@ "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], - "http-signature": ["http-signature@1.3.1", "", { "dependencies": { "assert-plus": "^1.0.0", "jsprim": "^1.2.2", "sshpk": "^1.14.1" } }, "sha512-Y29YKEc8MQsjch/VzkUVJ+2MXd9WcR42fK5u36CZf4G8bXw2DXMTWuESiB0R6m59JAWxlPPw5/Fri/t/AyyueA=="], - "http2-wrapper": ["http2-wrapper@2.2.1", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.2.0" } }, "sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ=="], "https": ["https@1.0.0", "", {}, "sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg=="], @@ -3414,8 +3389,6 @@ "isolated-vm": ["isolated-vm@6.2.0", "", { "dependencies": { "node-gyp-build": "^4.8.4" } }, "sha512-UuSlxSHWt2QuJ5WvBhzlIJx2VVZN/a44SqBbEZFKNdvuSyhOvhmyDo8SQ+njVbhnh/njoL/aW0bUTiFYlpweGQ=="], - "isomorphic-fetch": ["isomorphic-fetch@3.0.0", "", { "dependencies": { "node-fetch": "^2.6.1", "whatwg-fetch": "^3.4.1" } }, "sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA=="], - "isomorphic-ws": ["isomorphic-ws@5.0.0", "", { "peerDependencies": { "ws": "*" } }, "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw=="], "isomorphic.js": ["isomorphic.js@0.2.5", "", {}, "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw=="], @@ -3450,8 +3423,6 @@ "js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="], - "jsbn": ["jsbn@0.1.1", "", {}, "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg=="], - "jsdom": ["jsdom@26.1.0", "", { "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", "decimal.js": "^10.5.0", "html-encoding-sniffer": "^4.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "nwsapi": "^2.2.16", "parse5": "^7.2.1", "rrweb-cssom": "^0.8.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^5.1.1", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^7.0.0", "whatwg-encoding": "^3.1.1", "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.1.1", "ws": "^8.18.0", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg=="], "jsep": ["jsep@1.4.0", "", {}, "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw=="], @@ -3482,10 +3453,6 @@ "jsonwebtoken": ["jsonwebtoken@9.0.3", "", { "dependencies": { "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g=="], - "jsprim": ["jsprim@1.4.2", "", { "dependencies": { "assert-plus": "1.0.0", "extsprintf": "1.3.0", "json-schema": "0.4.0", "verror": "1.10.0" } }, "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw=="], - - "jssha": ["jssha@3.3.1", "", {}, "sha512-VCMZj12FCFMQYcFLPRm/0lOBbLi8uM2BhXPTqw3U4YAfs4AZfiApOoBLoN8cQE60Z50m1MYMTQVCfgF/KaCVhQ=="], - "jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="], "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], @@ -3886,8 +3853,6 @@ "obug": ["obug@2.1.3", "", {}, "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg=="], - "oci-common": ["oci-common@2.140.0", "", { "dependencies": { "@types/isomorphic-fetch": "0.0.35", "@types/jsonwebtoken": "9.0.3", "@types/opossum": "4.1.1", "@types/sshpk": "1.10.3", "@types/uuid": "8.3.4", "es6-promise": "4.2.6", "http-signature": "1.3.1", "isomorphic-fetch": "3.0.0", "jsonwebtoken": "9.0.3", "jssha": "3.3.1", "opossum": "5.0.1", "sshpk": "1.18.0", "uuid": "11.1.1" } }, "sha512-yHdfmB0gIx0QYC7sNvgll4HEw+w+fVo/eldhZfN2VxLJK+oqVHDlr6rT/ytwK8Fc8bS5XPkofIpCStPNR10MdQ=="], - "officeparser": ["officeparser@5.2.2", "", { "dependencies": { "@xmldom/xmldom": "^0.8.10", "concat-stream": "^2.0.0", "file-type": "^16.5.4", "node-ensure": "^0.0.0", "pdfjs-dist": "^5.3.31", "yauzl": "^3.1.3" }, "bin": { "officeparser": "officeParser.js" } }, "sha512-5JrV1CZFqTv/27fXy2bcf+3g6BpDZiJ3XoSRW3fb2i2EFex0DduqjTxiU2RsJ08WBsk4Hp0nZoGi9ZtHMZFaPA=="], "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], @@ -3918,8 +3883,6 @@ "opentype.js": ["opentype.js@1.3.4", "", { "dependencies": { "string.prototype.codepointat": "^0.2.1", "tiny-inflate": "^1.0.3" }, "bin": { "ot": "bin/ot" } }, "sha512-d2JE9RP/6uagpQAVtJoF0pJJA/fgai89Cc50Yp0EJHk+eLp6QQ7gBoblsnubRULNY132I0J1QKMJ+JTbMqz4sw=="], - "opossum": ["opossum@5.0.1", "", {}, "sha512-iUDUQmFl3RanaBVLMDTZ6WtXj/Hk84pwJ5JWoJaQd1lXGifdApHhszI3biZvdBDdpTERCmB6x+7+uNvzhzVZIg=="], - "option": ["option@0.2.4", "", {}, "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A=="], "ora": ["ora@4.1.1", "", { "dependencies": { "chalk": "^3.0.0", "cli-cursor": "^3.1.0", "cli-spinners": "^2.2.0", "is-interactive": "^1.0.0", "log-symbols": "^3.0.0", "mute-stream": "0.0.8", "strip-ansi": "^6.0.0", "wcwidth": "^1.0.1" } }, "sha512-sjYP8QyVWBpBZWD6Vr1M/KwknSw6kJOz41tvGMlwWeClHBtYKTbHMki1PsLZnxKpXMPbTKv9b3pjQu3REib96A=="], @@ -4388,8 +4351,6 @@ "ssh2": ["ssh2@1.17.0", "", { "dependencies": { "asn1": "^0.2.6", "bcrypt-pbkdf": "^1.0.2" }, "optionalDependencies": { "cpu-features": "~0.0.10", "nan": "^2.23.0" } }, "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ=="], - "sshpk": ["sshpk@1.18.0", "", { "dependencies": { "asn1": "~0.2.3", "assert-plus": "^1.0.0", "bcrypt-pbkdf": "^1.0.0", "dashdash": "^1.12.0", "ecc-jsbn": "~0.1.1", "getpass": "^0.1.1", "jsbn": "~0.1.0", "safer-buffer": "^2.0.2", "tweetnacl": "~0.14.0" }, "bin": { "sshpk-conv": "bin/sshpk-conv", "sshpk-sign": "bin/sshpk-sign", "sshpk-verify": "bin/sshpk-verify" } }, "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ=="], - "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], "standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="], @@ -4642,8 +4603,6 @@ "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], - "verror": ["verror@1.10.0", "", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw=="], - "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], "vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="], @@ -4684,8 +4643,6 @@ "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], - "whatwg-fetch": ["whatwg-fetch@3.6.20", "", {}, "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg=="], - "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], "whatwg-url": ["whatwg-url@14.2.0", "", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="], @@ -5070,8 +5027,6 @@ "@types/fs-extra/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], - "@types/jsonwebtoken/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], - "@types/keyv/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], "@types/mssql/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], @@ -5080,8 +5035,6 @@ "@types/nodemailer/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], - "@types/opossum/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], - "@types/readable-stream/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], "@types/readdir-glob/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], @@ -5094,8 +5047,6 @@ "@types/ssh2/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], - "@types/sshpk/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], - "@types/ws/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], "@vitest/expect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], @@ -5490,8 +5441,6 @@ "unzipper/fs-extra": ["fs-extra@11.3.1", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g=="], - "verror/core-util-is": ["core-util-is@1.0.2", "", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="], - "whatwg-encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], "widest-line/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -5704,8 +5653,6 @@ "@types/fs-extra/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], - "@types/jsonwebtoken/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], - "@types/keyv/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], "@types/mssql/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], @@ -5714,8 +5661,6 @@ "@types/nodemailer/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], - "@types/opossum/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], - "@types/readable-stream/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], "@types/readdir-glob/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], @@ -5728,8 +5673,6 @@ "@types/ssh2/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], - "@types/sshpk/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], - "@types/ws/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], "accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], From fe1e8d5d84839620425fec341381a7009fec3e26 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Thu, 3 Sep 2026 19:06:22 -0700 Subject: [PATCH 08/29] feat(credentials): complete OCI API key setup --- apps/docs/components/icons.tsx | 4 +- apps/docs/content/docs/cli/credentials.mdx | 5 + apps/docs/content/docs/cli/reference.mdx | 5 + apps/docs/openapi-v2-resources.json | 32 +- .../connect-service-account-modal.tsx | 223 +++++++++++- apps/sim/components/icons.tsx | 4 +- apps/sim/lib/api/contracts/credentials.ts | 21 +- apps/sim/lib/api/contracts/v2/credentials.ts | 24 +- .../application/provider-catalog.ts | 58 ++++ .../oci-api-key-service-account.server.ts | 320 ++++-------------- .../orchestration/credential-create.ts | 15 +- .../lib/credentials/orchestration/index.ts | 41 ++- .../lib/credentials/service-account-fields.ts | 13 + .../service-account-provider-ids.ts | 2 + .../lib/credentials/service-account-secret.ts | 56 ++- apps/sim/lib/oauth/oauth.ts | 18 + packages/sim-cli/src/generated/v2-api.ts | 10 + 17 files changed, 576 insertions(+), 275 deletions(-) diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index 867a81af5c2..dbea6ed5669 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -9394,7 +9394,7 @@ export function NewRelicIcon(props: SVGProps) { ) } -export function NetSuiteIcon(props: SVGProps) { +export function OracleIcon(props: SVGProps) { return ( ) { ) } +export const NetSuiteIcon = OracleIcon + export function WizaIcon(props: SVGProps) { return ( diff --git a/apps/docs/content/docs/cli/credentials.mdx b/apps/docs/content/docs/cli/credentials.mdx index aec2144c459..96972add528 100644 --- a/apps/docs/content/docs/cli/credentials.mdx +++ b/apps/docs/content/docs/cli/credentials.mdx @@ -113,6 +113,11 @@ Update Credential (personal API key required) | `--auth-method ` | No | Provider authentication method. | | `--private-key ` | No | Write-only PEM private key. | | `--username ` | No | Provider run-as username. | +| `--tenancy-ocid ` | No | OCI tenancy OCID. | +| `--user-ocid ` | No | OCI user OCID. | +| `--fingerprint ` | No | OCI API-key fingerprint. | +| `--private-key-passphrase ` | No | Write-only OCI private-key passphrase. | +| `--region ` | No | OCI home region. | | `--name ` | No | Alias for --display-name. | diff --git a/apps/docs/content/docs/cli/reference.mdx b/apps/docs/content/docs/cli/reference.mdx index 28bab99672f..d205651f0c9 100644 --- a/apps/docs/content/docs/cli/reference.mdx +++ b/apps/docs/content/docs/cli/reference.mdx @@ -473,6 +473,11 @@ sim credentials update [options] | `--auth-method ` | No | Provider authentication method. | | `--private-key ` | No | Write-only PEM private key. | | `--username ` | No | Provider run-as username. | +| `--tenancy-ocid ` | No | OCI tenancy OCID. | +| `--user-ocid ` | No | OCI user OCID. | +| `--fingerprint ` | No | OCI API-key fingerprint. | +| `--private-key-passphrase ` | No | Write-only OCI private-key passphrase. | +| `--region ` | No | OCI home region. | | `--name ` | No | Alias for --display-name. | diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index c2ff564af75..fc24556efc1 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -9386,13 +9386,43 @@ "writeOnly": true, "type": "string", "minLength": 1, - "maxLength": 8192 + "maxLength": 65536 }, "username": { "description": "Provider run-as username.", "type": "string", "minLength": 1, "maxLength": 255 + }, + "tenancyOcid": { + "description": "OCI tenancy OCID.", + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "userOcid": { + "description": "OCI user OCID.", + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "fingerprint": { + "description": "OCI API-key fingerprint.", + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "privateKeyPassphrase": { + "description": "Write-only OCI private-key passphrase.", + "writeOnly": true, + "type": "string", + "maxLength": 4096 + }, + "region": { + "description": "OCI home region.", + "type": "string", + "minLength": 1, + "maxLength": 128 } }, "additionalProperties": false, diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx index 0c87db8c28d..4ab50999337 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx @@ -25,6 +25,7 @@ import { import { getServiceAccountCoverageSentence } from '@/lib/integrations/credential-display' import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, + OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID, SLACK_CUSTOM_BOT_PROVIDER_ID, } from '@/lib/oauth/types' import { ClientCredentialAccountModal } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/client-credential-account-modal' @@ -44,13 +45,15 @@ export type ServiceAccountProviderId = | typeof GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID | typeof ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID | typeof SLACK_CUSTOM_BOT_PROVIDER_ID + | typeof OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID | TokenServiceAccountProviderId | ClientCredentialAccountProviderId -/** Sim setup guides for each provider, docked bottom-left of each modal. */ const GOOGLE_SERVICE_ACCOUNT_DOCS_URL = 'https://docs.sim.ai/integrations/google-service-account' const ATLASSIAN_SERVICE_ACCOUNT_DOCS_URL = 'https://docs.sim.ai/integrations/atlassian-service-account' +const OCI_API_KEY_DOCS_URL = + 'https://docs.oracle.com/en-us/iaas/Content/API/Concepts/apisigningkey.htm' function openDocs(url: string): void { window.open(url, '_blank', 'noopener,noreferrer') @@ -125,18 +128,6 @@ interface ConnectServiceAccountModalProps { onCreated?: (credentialId: string) => void } -/** - * Connect-service-account modal mounted from the per-integration detail page. - * Self-contained: takes the resolved SA provider + service metadata from the - * caller and submits via `useCreateWorkspaceCredential`. Branches the body - * based on `serviceAccountProviderId`: - * - * - `google-service-account`: JSON-paste + drag/drop. Validated client-side - * against {@link serviceAccountJsonSchema} before submitting. - * - `atlassian-service-account`: API token + site domain. Validated by the - * server against the Atlassian API; user-facing errors are mapped from the - * route's `error.code`. - */ export function ConnectServiceAccountModal({ open, onOpenChange, @@ -211,6 +202,22 @@ export function ConnectServiceAccountModal({ /> ) } + if (serviceAccountProviderId === OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID) { + return ( + + ) + } return ( void } +function OciApiKeyServiceAccountModal({ + open, + onOpenChange, + workspaceId, + serviceName, + serviceIcon: ServiceIcon, + credentialId, + initialDisplayName, + initialDescription, + onCreated, +}: ProviderModalProps) { + const [tenancyOcid, setTenancyOcid] = useState('') + const [userOcid, setUserOcid] = useState('') + const [fingerprint, setFingerprint] = useState('') + const [privateKey, setPrivateKey] = useState('') + const [privateKeyPassphrase, setPrivateKeyPassphrase] = useState('') + const [region, setRegion] = useState('') + const [displayName, setDisplayName] = useState(initialDisplayName ?? '') + const [description, setDescription] = useState(initialDescription ?? '') + const [error, setError] = useState(null) + const createCredential = useCreateWorkspaceCredential() + const updateCredential = useUpdateWorkspaceCredential() + + const isPending = createCredential.isPending || updateCredential.isPending + const isDisabled = + !tenancyOcid.trim() || + !userOcid.trim() || + !fingerprint.trim() || + !privateKey.trim() || + !region.trim() || + isPending + + const clearError = () => { + if (error) setError(null) + } + + const handleSubmit = async () => { + setError(null) + if (isDisabled) return + const fields = { + tenancyOcid: tenancyOcid.trim(), + userOcid: userOcid.trim(), + fingerprint: fingerprint.trim(), + privateKey, + ...(privateKeyPassphrase.length > 0 ? { privateKeyPassphrase } : {}), + region: region.trim(), + displayName: displayName.trim() || undefined, + description: description.trim() || undefined, + } + try { + let connectedCredentialId = credentialId + if (credentialId) { + await updateCredential.mutateAsync({ credentialId, ...fields }) + } else { + const created = await createCredential.mutateAsync({ + workspaceId, + type: 'service_account', + providerId: OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID, + ...fields, + }) + connectedCredentialId = created.credential.id + } + if (connectedCredentialId) onCreated?.(connectedCredentialId) + onOpenChange(false) + } catch (err: unknown) { + setError(getErrorMessage(err, 'Failed to add OCI API-key credential')) + logger.error('Failed to add OCI API-key credential', err) + } + } + + return ( + + onOpenChange(false)}> + Add {serviceName} API key + + + { + setTenancyOcid(value) + clearError() + }} + placeholder='ocid1.tenancy.oc1..' + autoComplete='off' + mono + required + /> + { + setUserOcid(value) + clearError() + }} + placeholder='ocid1.user.oc1..' + autoComplete='off' + mono + required + /> + { + setFingerprint(value) + clearError() + }} + placeholder='00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff' + autoComplete='off' + mono + required + /> + { + setPrivateKey(value) + clearError() + }} + placeholder='-----BEGIN PRIVATE KEY-----' + minHeight={120} + mono + required + /> + { + setPrivateKeyPassphrase(value) + clearError() + }} + placeholder='Optional' + autoComplete='new-password' + /> + { + setRegion(value) + clearError() + }} + placeholder='us-ashburn-1' + autoComplete='off' + mono + required + /> + + + {error} + + onOpenChange(false)} + secondaryActions={[{ label: 'Setup guide', onClick: () => openDocs(OCI_API_KEY_DOCS_URL) }]} + primaryAction={{ + label: isPending ? 'Adding...' : credentialId ? 'Reconnect' : 'Add API key', + onClick: handleSubmit, + disabled: isDisabled, + }} + /> + + ) +} + /** * Google service-account flow. Accepts the raw JSON key (paste or drag/drop) * and validates against the shared `serviceAccountJsonSchema` so the same diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index 867a81af5c2..dbea6ed5669 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -9394,7 +9394,7 @@ export function NewRelicIcon(props: SVGProps) { ) } -export function NetSuiteIcon(props: SVGProps) { +export function OracleIcon(props: SVGProps) { return ( ) { ) } +export const NetSuiteIcon = OracleIcon + export function WizaIcon(props: SVGProps) { return ( diff --git a/apps/sim/lib/api/contracts/credentials.ts b/apps/sim/lib/api/contracts/credentials.ts index 8be92a6a717..6f116135e30 100644 --- a/apps/sim/lib/api/contracts/credentials.ts +++ b/apps/sim/lib/api/contracts/credentials.ts @@ -158,9 +158,14 @@ export const createCredentialBodySchema = z */ authMethod: z.string().trim().min(1).max(64).optional(), /** PEM private key for certificate/JWT-based grants (for example Salesforce or NetSuite). */ - privateKey: z.string().trim().min(1).max(8192).optional(), + privateKey: z.string().trim().min(1).max(65_536).optional(), /** Run-as username for key-based grants (Salesforce JWT `sub`). */ username: z.string().trim().min(1).max(255).optional(), + tenancyOcid: z.string().trim().min(1).max(255).optional(), + userOcid: z.string().trim().min(1).max(255).optional(), + fingerprint: z.string().trim().min(1).max(128).optional(), + privateKeyPassphrase: z.string().max(4096).optional(), + region: z.string().trim().min(1).max(128).optional(), }) .superRefine((data, ctx) => { if (data.type === 'oauth') { @@ -240,8 +245,13 @@ export const updateCredentialByIdBodySchema = z orgId: z.string().trim().min(1).max(255).optional(), dataCenter: z.string().trim().min(1).max(32).optional(), authMethod: z.string().trim().min(1).max(64).optional(), - privateKey: z.string().trim().min(1).max(8192).optional(), + privateKey: z.string().trim().min(1).max(65_536).optional(), username: z.string().trim().min(1).max(255).optional(), + tenancyOcid: z.string().trim().min(1).max(255).optional(), + userOcid: z.string().trim().min(1).max(255).optional(), + fingerprint: z.string().trim().min(1).max(128).optional(), + privateKeyPassphrase: z.string().max(4096).optional(), + region: z.string().trim().min(1).max(128).optional(), }) .strict() .refine( @@ -261,7 +271,12 @@ export const updateCredentialByIdBodySchema = z data.dataCenter !== undefined || data.authMethod !== undefined || data.privateKey !== undefined || - data.username !== undefined, + data.username !== undefined || + data.tenancyOcid !== undefined || + data.userOcid !== undefined || + data.fingerprint !== undefined || + data.privateKeyPassphrase !== undefined || + data.region !== undefined, { message: 'At least one field must be provided', path: ['displayName'], diff --git a/apps/sim/lib/api/contracts/v2/credentials.ts b/apps/sim/lib/api/contracts/v2/credentials.ts index a5fc8144d37..dd9785585bf 100644 --- a/apps/sim/lib/api/contracts/v2/credentials.ts +++ b/apps/sim/lib/api/contracts/v2/credentials.ts @@ -458,11 +458,21 @@ const v2ServiceAccountCredentialFieldsSchema = z .string() .trim() .min(1) - .max(8192) + .max(65_536) .optional() .describe('Write-only PEM private key.') .meta({ writeOnly: true }), username: z.string().trim().min(1).max(255).optional().describe('Provider run-as username.'), + tenancyOcid: z.string().trim().min(1).max(255).optional().describe('OCI tenancy OCID.'), + userOcid: z.string().trim().min(1).max(255).optional().describe('OCI user OCID.'), + fingerprint: z.string().trim().min(1).max(128).optional().describe('OCI API-key fingerprint.'), + privateKeyPassphrase: z + .string() + .max(4096) + .optional() + .describe('Write-only OCI private-key passphrase.') + .meta({ writeOnly: true }), + region: z.string().trim().min(1).max(128).optional().describe('OCI home region.'), }) .strict() @@ -707,11 +717,21 @@ const v2ServiceAccountSecretFieldsShape = { .string() .trim() .min(1) - .max(8192) + .max(65_536) .optional() .describe('Write-only PEM private key.') .meta({ writeOnly: true }), username: z.string().trim().min(1).max(255).optional().describe('Provider run-as username.'), + tenancyOcid: z.string().trim().min(1).max(255).optional().describe('OCI tenancy OCID.'), + userOcid: z.string().trim().min(1).max(255).optional().describe('OCI user OCID.'), + fingerprint: z.string().trim().min(1).max(128).optional().describe('OCI API-key fingerprint.'), + privateKeyPassphrase: z + .string() + .max(4096) + .optional() + .describe('Write-only OCI private-key passphrase.') + .meta({ writeOnly: true }), + region: z.string().trim().min(1).max(128).optional().describe('OCI home region.'), } as const export const v2UpdateCredentialBodySchema = z diff --git a/apps/sim/lib/credentials/application/provider-catalog.ts b/apps/sim/lib/credentials/application/provider-catalog.ts index 371aab2edf9..4f933f76483 100644 --- a/apps/sim/lib/credentials/application/provider-catalog.ts +++ b/apps/sim/lib/credentials/application/provider-catalog.ts @@ -15,6 +15,7 @@ import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID, type OAuthServiceMetadata, + OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID, SLACK_CUSTOM_BOT_PROVIDER_ID, } from '@/lib/oauth/types' import { getAllOAuthServices, getServiceConfigByServiceId } from '@/lib/oauth/utils' @@ -180,6 +181,63 @@ function getServiceAccountDescriptor(providerId: string): ServiceAccountDescript ], } } + if (providerId === OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID) { + return { + name: 'OCI API key', + description: 'Connect Oracle Cloud Infrastructure with an API signing key.', + docsUrl: 'https://docs.oracle.com/en-us/iaas/Content/API/Concepts/apisigningkey.htm', + fields: [ + { + id: 'tenancyOcid', + label: 'Tenancy OCID', + placeholder: 'ocid1.tenancy.oc1..', + required: true, + secret: false, + multiline: false, + }, + { + id: 'userOcid', + label: 'User OCID', + placeholder: 'ocid1.user.oc1..', + required: true, + secret: false, + multiline: false, + }, + { + id: 'fingerprint', + label: 'Fingerprint', + placeholder: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff', + required: true, + secret: false, + multiline: false, + }, + { + id: 'privateKey', + label: 'Private key', + placeholder: '-----BEGIN PRIVATE KEY-----', + required: true, + secret: true, + multiline: true, + }, + { + id: 'privateKeyPassphrase', + label: 'Private-key passphrase', + placeholder: 'Optional', + required: false, + secret: true, + multiline: false, + }, + { + id: 'region', + label: 'Region', + placeholder: 'us-ashburn-1', + required: true, + secret: false, + multiline: false, + }, + ], + } + } const tokenDescriptor = Object.hasOwn(TOKEN_SERVICE_ACCOUNT_DESCRIPTORS, providerId) ? TOKEN_SERVICE_ACCOUNT_DESCRIPTORS[ diff --git a/apps/sim/lib/credentials/oci-api-key-service-account.server.ts b/apps/sim/lib/credentials/oci-api-key-service-account.server.ts index 6f0f7de6d38..4a1cb0e50c8 100644 --- a/apps/sim/lib/credentials/oci-api-key-service-account.server.ts +++ b/apps/sim/lib/credentials/oci-api-key-service-account.server.ts @@ -1,18 +1,10 @@ import { createHash, createPrivateKey, createPublicKey } from 'node:crypto' -import { db } from '@sim/db' -import { credential } from '@sim/db/schema' import { safeCompare } from '@sim/security/compare' -import { eq } from 'drizzle-orm' -import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' +import { encryptSecret } from '@/lib/core/security/encryption' import { serviceAccountPrincipalMetadata } from '@/lib/credentials/principal' -import { sendOciRequest } from '@/lib/internal/oci/client.server' -import { - getOciRegion, - objectStorageOciDestination, - resolveEffectiveOciRegion, -} from '@/lib/internal/oci/endpoints' -import { OciRequestError } from '@/lib/internal/oci/errors' -import type { OciSigningCredentials } from '@/lib/internal/oci/signing.server' +import { verifyOciApiKeyCredentialForSetup } from '@/lib/internal/oci/client.server' +import { getOciRegion } from '@/lib/internal/oci/endpoints' +import { OciClientError } from '@/lib/internal/oci/errors' import { OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID, OCI_API_KEY_SERVICE_ACCOUNT_SECRET_TYPE, @@ -21,25 +13,28 @@ import { const MAX_OCID_LENGTH = 255 const MAX_PRIVATE_KEY_BYTES = 64 * 1024 const MAX_PASSPHRASE_BYTES = 4 * 1024 -const OCI_VERIFICATION_TIMEOUT_MS = 10_000 -const OCI_VERIFICATION_RESPONSE_BYTES = 64 * 1024 const OCID_PATTERN = /^ocid1\.([a-z][a-z0-9_-]*)\.([a-z0-9]+)\.([a-z0-9-]*)\.([a-zA-Z0-9_-]+)$/ const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f]/ const PEM_CONTROL_CHARACTER_PATTERN = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/ export interface OciApiKeyCredentialFields { - tenancyId: string - userId: string + tenancyOcid: string + userOcid: string fingerprint: string privateKey: string - passphrase?: string - defaultRegion: string + privateKeyPassphrase?: string + region: string } -export interface OciApiKeyServiceAccountSecret extends OciSigningCredentials { +interface OciApiKeyServiceAccountSecret { readonly type: typeof OCI_API_KEY_SERVICE_ACCOUNT_SECRET_TYPE readonly providerId: typeof OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID - readonly defaultRegion: string + readonly tenancyOcid: string + readonly userOcid: string + readonly fingerprint: string + readonly privateKey: string + readonly privateKeyPassphrase?: string + readonly region: string readonly metadata: { readonly principalKind: 'user' readonly principalId: string @@ -78,10 +73,7 @@ function assertBoundedText( function normalizeOcid( value: unknown, expectedType: 'tenancy' | 'user' -): { - value: string - realmId: string -} { +): { value: string; realmId: string } { assertBoundedText(value, `${expectedType} OCID`, MAX_OCID_LENGTH) const normalized = value.trim() const match = OCID_PATTERN.exec(normalized) @@ -91,11 +83,10 @@ function normalizeOcid( return { value: normalized, realmId: match[2] } } -export function normalizeOciFingerprint(value: unknown): string { +function normalizeFingerprint(value: unknown): string { assertBoundedText(value, 'fingerprint', 128) const hex = value.replace(/[:\s]/g, '').toLowerCase() - if (!/^[0-9a-f]{32}$/.test(hex)) throw new Error('OCI fingerprint must contain 16 MD5 bytes') - const bytes = hex.match(/.{2}/g) + const bytes = /^[0-9a-f]{32}$/.test(hex) ? hex.match(/.{2}/g) : null if (!bytes) throw new Error('OCI fingerprint must contain 16 MD5 bytes') return bytes.join(':') } @@ -121,261 +112,88 @@ function validatePassphrase(value: unknown): string | undefined { return value } -function validatePrivateKeyAndFingerprint(params: { - privateKey: string - passphrase?: string - fingerprint: string -}): void { +function buildSecret(fields: OciApiKeyCredentialFields): OciApiKeyServiceAccountSecret { + const tenancy = normalizeOcid(fields.tenancyOcid, 'tenancy') + const user = normalizeOcid(fields.userOcid, 'user') + if (tenancy.realmId !== user.realmId) { + throw new Error('OCI tenancy and user OCIDs must share a realm') + } + assertBoundedText(fields.region, 'region', 128) + const region = getOciRegion(fields.region) + if (region.realm.id !== tenancy.realmId) { + throw new Error('OCI region must belong to the credential realm') + } + const fingerprint = normalizeFingerprint(fields.fingerprint) + const privateKey = normalizePrivateKey(fields.privateKey) + const privateKeyPassphrase = validatePassphrase(fields.privateKeyPassphrase) + let key try { key = createPrivateKey({ - key: params.privateKey, + key: privateKey, format: 'pem', - ...(params.passphrase !== undefined ? { passphrase: params.passphrase } : {}), + ...(privateKeyPassphrase !== undefined ? { passphrase: privateKeyPassphrase } : {}), }) } catch { throw new Error('OCI private key or passphrase is invalid') } if (key.asymmetricKeyType !== 'rsa') throw new Error('OCI private key must use RSA') - const modulusLength = key.asymmetricKeyDetails?.modulusLength - if (modulusLength === undefined || modulusLength < 2048) { + if ( + key.asymmetricKeyDetails?.modulusLength === undefined || + key.asymmetricKeyDetails.modulusLength < 2048 + ) { throw new Error('OCI RSA private key must be at least 2048 bits') } const spki = createPublicKey(key).export({ format: 'der', type: 'spki' }) - const derivedHex = createHash('md5').update(spki).digest('hex') - const submittedHex = params.fingerprint.replaceAll(':', '') - const fingerprintsMatch = safeCompare( - Buffer.from(derivedHex, 'hex').toString('base64'), - Buffer.from(submittedHex, 'hex').toString('base64') - ) - if (!fingerprintsMatch) throw new Error('OCI fingerprint does not match the private key') -} - -/** Validates and normalizes credential fields without performing I/O. */ -export function buildOciApiKeyServiceAccountSecret( - fields: OciApiKeyCredentialFields -): OciApiKeyServiceAccountSecret { - const tenancy = normalizeOcid(fields.tenancyId, 'tenancy') - const user = normalizeOcid(fields.userId, 'user') - if (tenancy.realmId !== user.realmId) - throw new Error('OCI tenancy and user OCIDs must share a realm') - - assertBoundedText(fields.defaultRegion, 'default region', 128) - const defaultRegion = fields.defaultRegion.trim().toLowerCase() - const region = getOciRegion(defaultRegion) - if (region.realm.id !== tenancy.realmId) { - throw new Error('OCI default region must belong to the credential realm') + const derived = createHash('md5').update(spki).digest().toString('base64') + const submitted = Buffer.from(fingerprint.replaceAll(':', ''), 'hex').toString('base64') + if (!safeCompare(derived, submitted)) { + throw new Error('OCI fingerprint does not match the private key') } - const fingerprint = normalizeOciFingerprint(fields.fingerprint) - const privateKey = normalizePrivateKey(fields.privateKey) - const passphrase = validatePassphrase(fields.passphrase) - validatePrivateKeyAndFingerprint({ privateKey, passphrase, fingerprint }) const metadata = serviceAccountPrincipalMetadata({ kind: 'user', id: user.value }) - return { type: OCI_API_KEY_SERVICE_ACCOUNT_SECRET_TYPE, providerId: OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID, - tenancyId: tenancy.value, - userId: user.value, + tenancyOcid: tenancy.value, + userOcid: user.value, fingerprint, privateKey, - ...(passphrase !== undefined ? { passphrase } : {}), - defaultRegion, + ...(privateKeyPassphrase !== undefined ? { privateKeyPassphrase } : {}), + region: region.id, metadata: { principalKind: 'user', principalId: metadata.principalId }, } } -export function serializeOciApiKeyServiceAccountSecret( - secret: OciApiKeyServiceAccountSecret -): string { - return JSON.stringify(secret) -} - -function assertExactKeys( - record: Record, - required: readonly string[], - optional: readonly string[] = [] -): void { - const keys = Object.keys(record) - if ( - required.some((key) => !Object.hasOwn(record, key)) || - keys.some((key) => !required.includes(key) && !optional.includes(key)) - ) { - throw new Error('Stored OCI API-key credential is malformed') - } -} - -/** Strictly parses and revalidates an encrypted OCI credential payload. */ -export function parseOciApiKeyServiceAccountSecret( - serialized: string, - expectedProviderId: string = OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID -): OciApiKeyServiceAccountSecret { - let parsed: unknown - try { - parsed = JSON.parse(serialized) - } catch { - throw new Error('Stored OCI API-key credential is malformed') - } - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - throw new Error('Stored OCI API-key credential is malformed') - } - const record = parsed as Record - assertExactKeys( - record, - [ - 'type', - 'providerId', - 'tenancyId', - 'userId', - 'fingerprint', - 'privateKey', - 'defaultRegion', - 'metadata', - ], - ['passphrase'] - ) - if ( - record.type !== OCI_API_KEY_SERVICE_ACCOUNT_SECRET_TYPE || - record.providerId !== expectedProviderId || - expectedProviderId !== OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID || - !record.metadata || - typeof record.metadata !== 'object' || - Array.isArray(record.metadata) - ) { - throw new Error('Stored OCI API-key credential is malformed') - } - const metadata = record.metadata as Record - assertExactKeys(metadata, ['principalKind', 'principalId']) - let passphrase: string | undefined - if (Object.hasOwn(record, 'passphrase')) { - if (typeof record.passphrase !== 'string') { - throw new Error('Stored OCI API-key credential is malformed') - } - passphrase = record.passphrase - } - if ( - typeof record.tenancyId !== 'string' || - typeof record.userId !== 'string' || - typeof record.fingerprint !== 'string' || - typeof record.privateKey !== 'string' || - typeof record.defaultRegion !== 'string' - ) { - throw new Error('Stored OCI API-key credential is malformed') - } - let rebuilt: OciApiKeyServiceAccountSecret - try { - rebuilt = buildOciApiKeyServiceAccountSecret({ - tenancyId: record.tenancyId, - userId: record.userId, - fingerprint: record.fingerprint, - privateKey: record.privateKey, - ...(passphrase !== undefined ? { passphrase } : {}), - defaultRegion: record.defaultRegion, - }) - } catch { - throw new Error('Stored OCI API-key credential is malformed') - } - if ( - metadata.principalKind !== 'user' || - metadata.principalId !== rebuilt.userId || - record.tenancyId !== rebuilt.tenancyId || - record.userId !== rebuilt.userId || - record.fingerprint !== rebuilt.fingerprint || - record.privateKey !== rebuilt.privateKey || - record.defaultRegion !== rebuilt.defaultRegion || - record.passphrase !== rebuilt.passphrase - ) { - throw new Error('Stored OCI API-key credential is malformed') - } - return rebuilt -} - -/** Verifies a locally valid credential with Object Storage GetNamespace. */ -export async function verifyOciApiKeyCredential( - secret: OciApiKeyServiceAccountSecret, +/** Validates, verifies with GetNamespace, and only then encrypts an OCI credential. */ +export async function verifyAndEncryptOciApiKeyCredential( + fields: OciApiKeyCredentialFields, signal?: AbortSignal -): Promise<{ namespace: string }> { - const region = resolveEffectiveOciRegion(secret.defaultRegion) +): Promise<{ encryptedServiceAccountKey: string; userOcid: string }> { + const secret = buildSecret(fields) + let responseBody: Uint8Array try { - const result = await sendOciRequest({ - destination: objectStorageOciDestination(region), - credentials: secret, - method: 'GET', - encodedPath: '/n/', - timeout: OCI_VERIFICATION_TIMEOUT_MS, - maxResponseBytes: OCI_VERIFICATION_RESPONSE_BYTES, - signal, - serviceHeaders: { accept: 'application/json' }, - }) - const parsed: unknown = JSON.parse(await result.response.text()) - if ( - typeof parsed !== 'string' || - parsed.length === 0 || - Buffer.byteLength(parsed, 'utf8') > 255 || - CONTROL_CHARACTER_PATTERN.test(parsed) - ) { - throw new OciCredentialVerificationError('invalid_response') - } - return { namespace: parsed } + responseBody = await verifyOciApiKeyCredentialForSetup(JSON.stringify(secret), signal) } catch (error) { - if (error instanceof OciCredentialVerificationError) throw error if (signal?.aborted) throw error - if (error instanceof OciRequestError && (error.status === 401 || error.status === 403)) { + if (error instanceof OciClientError && (error.status === 401 || error.status === 403)) { throw new OciCredentialVerificationError('invalid_credentials') } - if (error instanceof SyntaxError) { - throw new OciCredentialVerificationError('invalid_response') - } throw new OciCredentialVerificationError('service_unavailable') } -} - -/** Validates, verifies, then encrypts an OCI credential in that order. */ -export async function verifyAndEncryptOciApiKeyCredential( - fields: OciApiKeyCredentialFields, - signal?: AbortSignal -): Promise<{ encryptedServiceAccountKey: string; namespace: string }> { - const secret = buildOciApiKeyServiceAccountSecret(fields) - const { namespace } = await verifyOciApiKeyCredential(secret, signal) - const { encrypted } = await encryptSecret(serializeOciApiKeyServiceAccountSecret(secret)) - return { encryptedServiceAccountKey: encrypted, namespace } -} - -interface OciCredentialRowProjection { - type: string - providerId: string | null - encryptedServiceAccountKey: string | null -} - -async function findOciCredentialById( - credentialId: string -): Promise { - const [row] = await db - .select({ - type: credential.type, - providerId: credential.providerId, - encryptedServiceAccountKey: credential.encryptedServiceAccountKey, - }) - .from(credential) - .where(eq(credential.id, credentialId)) - .limit(1) - return row ?? null -} - -/** Loads one provider-bound OCI credential, checking outer binding before decryption. */ -export async function loadOciApiKeyCredential( - credentialId: string -): Promise { - const row = await findOciCredentialById(credentialId) - if ( - !row || - row.type !== 'service_account' || - row.providerId !== OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID || - !row.encryptedServiceAccountKey - ) { - throw new Error('OCI API-key credential is unavailable or provider-mismatched') + try { + const namespace: unknown = JSON.parse(Buffer.from(responseBody).toString('utf8')) + if ( + typeof namespace !== 'string' || + namespace.length === 0 || + Buffer.byteLength(namespace, 'utf8') > 255 || + CONTROL_CHARACTER_PATTERN.test(namespace) + ) { + throw new Error('invalid namespace') + } + } catch { + throw new OciCredentialVerificationError('invalid_response') } - const { decrypted } = await decryptSecret(row.encryptedServiceAccountKey) - return parseOciApiKeyServiceAccountSecret(decrypted, row.providerId) + const { encrypted } = await encryptSecret(JSON.stringify(secret)) + return { encryptedServiceAccountKey: encrypted, userOcid: secret.userOcid } } diff --git a/apps/sim/lib/credentials/orchestration/credential-create.ts b/apps/sim/lib/credentials/orchestration/credential-create.ts index 3eb5903815c..ac8a95a1f59 100644 --- a/apps/sim/lib/credentials/orchestration/credential-create.ts +++ b/apps/sim/lib/credentials/orchestration/credential-create.ts @@ -78,6 +78,11 @@ export interface PerformCreateCredentialParams { authMethod?: string privateKey?: string username?: string + tenancyOcid?: string + userOcid?: string + fingerprint?: string + privateKeyPassphrase?: string + region?: string /** * Client-supplied credential id, honored only for `slack-custom-bot`: the * setup modal shows the ingest URL `/api/webhooks/slack/custom/{id}` before @@ -276,6 +281,11 @@ export async function createCredentialRecord( authMethod: params.authMethod, privateKey: params.privateKey, username: params.username, + tenancyOcid: params.tenancyOcid, + userOcid: params.userOcid, + fingerprint: params.fingerprint, + privateKeyPassphrase: params.privateKeyPassphrase, + region: params.region, }) resolvedProviderId = secret.providerId resolvedAccountId = null @@ -285,7 +295,10 @@ export async function createCredentialRecord( Object.assign(extraAuditMetadata, secret.auditMetadata) } catch (error) { if (error instanceof ServiceAccountSecretError) { - return failure(error.message, 'validation') + return failure(error.message, 'validation', { + providerErrorCode: error.providerErrorCode, + providerUnavailable: isProviderOutageCode(error.providerErrorCode), + }) } throw error } diff --git a/apps/sim/lib/credentials/orchestration/index.ts b/apps/sim/lib/credentials/orchestration/index.ts index 47cc51e4927..199ac826a01 100644 --- a/apps/sim/lib/credentials/orchestration/index.ts +++ b/apps/sim/lib/credentials/orchestration/index.ts @@ -44,6 +44,7 @@ import { TokenServiceAccountValidationError } from '@/lib/credentials/token-serv import { invalidateEffectiveDecryptedEnvCache } from '@/lib/environment/utils' import { GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID, + OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID, SLACK_CUSTOM_BOT_PROVIDER_ID, SLACK_CUSTOM_BOT_SECRET_TYPE, } from '@/lib/oauth/types' @@ -82,6 +83,11 @@ const ROTATABLE_SECRET_FIELDS: readonly ServiceAccountFieldId[] = [ 'authMethod', 'privateKey', 'username', + 'tenancyOcid', + 'userOcid', + 'fingerprint', + 'privateKeyPassphrase', + 'region', ] /** @@ -194,6 +200,11 @@ export interface PerformUpdateCredentialParams extends CredentialActorParams { authMethod?: string privateKey?: string username?: string + tenancyOcid?: string + userOcid?: string + fingerprint?: string + privateKeyPassphrase?: string + region?: string } export interface PerformCredentialResult { @@ -285,6 +296,24 @@ export async function updateCredentialRecord( if (hasRotationSecret) { const providerId = params.credential.providerId ?? '' + if (providerId === OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID) { + const requiredOciFields = [ + 'tenancyOcid', + 'userOcid', + 'fingerprint', + 'privateKey', + 'region', + ] as const + const missingOciFields = requiredOciFields.filter((field) => params[field] === undefined) + if (missingOciFields.length > 0) { + return { + success: false, + error: `OCI credential rotation requires the complete signing tuple; missing ${missingOciFields.join(', ')}`, + errorCode: 'validation', + } + } + } + // A reconnect rebuilds the secret blob from the submitted fields only, and // the modal never prefills (secrets are never echoed back). For an actual // secret that is correct - the admin retypes it. But a non-secret selector @@ -370,6 +399,11 @@ export async function updateCredentialRecord( : params.authMethod, privateKey: params.privateKey, username: needsStoredUsername ? readStoredField(storedBlob, 'username') : params.username, + tenancyOcid: params.tenancyOcid, + userOcid: params.userOcid, + fingerprint: params.fingerprint, + privateKeyPassphrase: params.privateKeyPassphrase, + region: params.region, }) updates.encryptedServiceAccountKey = secret.encryptedServiceAccountKey rotatedSlackBotUserId = secret.botUserId @@ -388,7 +422,12 @@ export async function updateCredentialRecord( } } catch (error) { if (error instanceof ServiceAccountSecretError) { - return { success: false, error: error.message, errorCode: 'validation' } + return { + success: false, + error: error.message, + errorCode: 'validation', + providerErrorCode: error.providerErrorCode, + } } if (error instanceof AtlassianValidationError) { // Surface the provider code so the client maps it to the specific diff --git a/apps/sim/lib/credentials/service-account-fields.ts b/apps/sim/lib/credentials/service-account-fields.ts index f1bac216036..31bea7ac61b 100644 --- a/apps/sim/lib/credentials/service-account-fields.ts +++ b/apps/sim/lib/credentials/service-account-fields.ts @@ -3,6 +3,7 @@ import { TOKEN_SERVICE_ACCOUNT_REQUIRED_FIELDS } from '@/lib/credentials/token-s import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID, + OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID, SLACK_CUSTOM_BOT_PROVIDER_ID, } from '@/lib/oauth/types' @@ -21,6 +22,11 @@ export type ServiceAccountFieldId = | 'authMethod' | 'privateKey' | 'username' + | 'tenancyOcid' + | 'userOcid' + | 'fingerprint' + | 'privateKeyPassphrase' + | 'region' /** * Required create-body fields per service-account provider — the client-safe @@ -36,6 +42,13 @@ export const SERVICE_ACCOUNT_REQUIRED_FIELDS: Record { + const { tenancyOcid, userOcid, fingerprint, privateKey, privateKeyPassphrase, region } = fields + if (!tenancyOcid || !userOcid || !fingerprint || !privateKey || !region) { + throw new ServiceAccountSecretError( + 'tenancyOcid, userOcid, fingerprint, privateKey, and region are required for OCI API-key credentials' + ) + } + try { + const result = await verifyAndEncryptOciApiKeyCredential({ + tenancyOcid, + userOcid, + fingerprint, + privateKey, + ...(privateKeyPassphrase !== undefined ? { privateKeyPassphrase } : {}), + region, + }) + const principal: ServiceAccountPrincipal = { kind: 'user', id: result.userOcid } + const metadata = serviceAccountPrincipalMetadata(principal) + return { + providerId: OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID, + encryptedServiceAccountKey: result.encryptedServiceAccountKey, + displayName: result.userOcid, + auditMetadata: metadata, + principal, + } + } catch (error) { + if (error instanceof OciCredentialVerificationError) { + throw new ServiceAccountSecretError( + error.code === 'service_unavailable' + ? 'OCI is temporarily unavailable for credential verification' + : 'OCI rejected the API-key credential', + error.code === 'service_unavailable' ? 'provider_unavailable' : 'invalid_credentials' + ) + } + throw new ServiceAccountSecretError('OCI API-key credential is invalid') + } +} + /** * Builds a token-paste service-account secret for any provider registered in * `TOKEN_SERVICE_ACCOUNT_DESCRIPTORS`: verifies the pasted token via the @@ -350,6 +403,7 @@ const SERVICE_ACCOUNT_SECRET_BUILDERS: Record = { }, defaultService: 'netsuite', }, + oci: { + name: 'Oracle Cloud Infrastructure', + icon: OracleIcon, + services: { + oci: { + name: 'Oracle Cloud Infrastructure', + description: 'Connect OCI services with an API signing key.', + providerId: 'oci', + serviceAccountProviderId: 'oci-api-key-service-account', + icon: OracleIcon, + baseProviderIcon: OracleIcon, + scopes: [], + authType: 'service_account', + }, + }, + defaultService: 'oci', + }, reddit: { name: 'Reddit', icon: RedditIcon, diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index bf267c19872..6e0ebc22a43 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -8297,6 +8297,11 @@ export type UpdateCredentialBody = { authMethod?: string privateKey?: string username?: string + tenancyOcid?: string + userOcid?: string + fingerprint?: string + privateKeyPassphrase?: string + region?: string } type UpdateCredentialResponseRef0 = { @@ -14341,6 +14346,11 @@ export const V2_OPERATIONS = { authMethod: { kind: 'string', describe: 'Provider authentication method.' }, privateKey: { kind: 'string', describe: 'Write-only PEM private key.' }, username: { kind: 'string', describe: 'Provider run-as username.' }, + tenancyOcid: { kind: 'string', describe: 'OCI tenancy OCID.' }, + userOcid: { kind: 'string', describe: 'OCI user OCID.' }, + fingerprint: { kind: 'string', describe: 'OCI API-key fingerprint.' }, + privateKeyPassphrase: { kind: 'string', describe: 'Write-only OCI private-key passphrase.' }, + region: { kind: 'string', describe: 'OCI home region.' }, }, }, updateCustomTool: { From cf39099c8e784f0778ecd6c3b0c93f873f008ae9 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Thu, 3 Sep 2026 19:06:38 -0700 Subject: [PATCH 09/29] test(oci): add signing and transport conformance --- apps/sim/app/api/credentials/route.test.ts | 64 + .../credentials/[credentialId]/route.test.ts | 27 + apps/sim/app/api/v2/credentials/route.test.ts | 44 + .../sim/lib/api/contracts/credentials.test.ts | 45 + .../application/provider-catalog.test.ts | 40 + ...oci-api-key-service-account.server.test.ts | 383 ++--- .../credentials/orchestration/index.test.ts | 52 + .../service-account-provider-ids.test.ts | 3 + .../service-account-secret.test.ts | 78 + .../lib/internal/oci/client.server.test.ts | 1259 +++++++++-------- apps/sim/lib/internal/oci/endpoints.test.ts | 167 ++- apps/sim/lib/oauth/credential-service.test.ts | 18 +- apps/sim/lib/oauth/token-resolution.test.ts | 148 +- apps/sim/lib/oauth/utils.test.ts | 4 + .../lib/selectors/server/credentials.test.ts | 5 +- apps/sim/tools/index.test.ts | 45 + 16 files changed, 1456 insertions(+), 926 deletions(-) diff --git a/apps/sim/app/api/credentials/route.test.ts b/apps/sim/app/api/credentials/route.test.ts index 832a516763e..bedc7b42605 100644 --- a/apps/sim/app/api/credentials/route.test.ts +++ b/apps/sim/app/api/credentials/route.test.ts @@ -550,4 +550,68 @@ describe('POST /api/credentials', () => { expect(dbChainMockFns.insert).not.toHaveBeenCalled() }) }) + + it('forwards OCI API-key fields without returning secret material', async () => { + mockVerifyAndBuildServiceAccountSecret.mockResolvedValueOnce({ + providerId: 'oci-api-key-service-account', + encryptedServiceAccountKey: 'encrypted-oci-blob', + displayName: 'ocid1.user.oc1..principal', + auditMetadata: { + principalKind: 'user', + principalId: 'ocid1.user.oc1..principal', + }, + principal: { kind: 'user', id: 'ocid1.user.oc1..principal' }, + }) + queueTableRows(credential, []) + queueTableRows(credential, []) + queueTableRows(credential, [ + { + id: 'credential-oci', + workspaceId: WORKSPACE_ID, + type: 'service_account', + displayName: 'ocid1.user.oc1..principal', + description: null, + unredacted: false, + providerId: 'oci-api-key-service-account', + accountId: null, + envKey: null, + envOwnerUserId: null, + encryptedServiceAccountKey: 'encrypted-oci-blob', + createdBy: 'user-1', + createdAt: new Date('2026-08-11T00:00:00.000Z'), + updatedAt: new Date('2026-08-11T00:00:00.000Z'), + }, + ]) + + const response = await POST( + createMockRequest('POST', { + workspaceId: WORKSPACE_ID, + type: 'service_account', + providerId: 'oci-api-key-service-account', + tenancyOcid: 'ocid1.tenancy.oc1..tenant', + userOcid: 'ocid1.user.oc1..principal', + fingerprint: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff', + privateKey: '-----BEGIN PRIVATE KEY-----\nkey\n-----END PRIVATE KEY-----', + privateKeyPassphrase: ' exact passphrase ', + region: 'us-ashburn-1', + }) + ) + const body = await response.text() + + expect(response.status).toBe(201) + expect(mockVerifyAndBuildServiceAccountSecret).toHaveBeenCalledWith( + 'oci-api-key-service-account', + expect.objectContaining({ + tenancyOcid: 'ocid1.tenancy.oc1..tenant', + userOcid: 'ocid1.user.oc1..principal', + fingerprint: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff', + privateKey: '-----BEGIN PRIVATE KEY-----\nkey\n-----END PRIVATE KEY-----', + privateKeyPassphrase: ' exact passphrase ', + region: 'us-ashburn-1', + }) + ) + expect(body).not.toContain('PRIVATE KEY') + expect(body).not.toContain('exact passphrase') + expect(body).not.toContain('encrypted-oci-blob') + }) }) diff --git a/apps/sim/app/api/v2/credentials/[credentialId]/route.test.ts b/apps/sim/app/api/v2/credentials/[credentialId]/route.test.ts index 97c34f00802..4a314175549 100644 --- a/apps/sim/app/api/v2/credentials/[credentialId]/route.test.ts +++ b/apps/sim/app/api/v2/credentials/[credentialId]/route.test.ts @@ -127,6 +127,33 @@ describe('PATCH /api/v2/credentials/[credentialId]', () => { expect(body).not.toContain('MUST_NOT_LEAK_CIPHERTEXT') }) + it('forwards a complete OCI rotation tuple and preserves explicit passphrase clearing', async () => { + const request = patchRequest({ + tenancyOcid: 'ocid1.tenancy.oc1..tenant', + userOcid: 'ocid1.user.oc1..replacement', + fingerprint: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff', + privateKey: '-----BEGIN PRIVATE KEY-----\nreplacement\n-----END PRIVATE KEY-----', + region: 'us-ashburn-1', + }) + const response = await PATCH(request, context) + + expect(response.status).toBe(200) + expect(mocks.update).toHaveBeenCalledWith({ + principal: auth.principal, + input: { + tenancyOcid: 'ocid1.tenancy.oc1..tenant', + userOcid: 'ocid1.user.oc1..replacement', + fingerprint: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff', + privateKey: '-----BEGIN PRIVATE KEY-----\nreplacement\n-----END PRIVATE KEY-----', + region: 'us-ashburn-1', + credentialId: CREDENTIAL_ID, + assertedWorkspaceId: WORKSPACE_ID, + }, + request, + }) + expect(JSON.stringify(await response.json())).not.toContain('PRIVATE KEY') + }) + it('asserts the workspace scope and preserves the credential id', async () => { const request = patchRequest({ displayName: 'Zoom prod' }) await PATCH(request, context) diff --git a/apps/sim/app/api/v2/credentials/route.test.ts b/apps/sim/app/api/v2/credentials/route.test.ts index 33ee438a12b..248aff80ea0 100644 --- a/apps/sim/app/api/v2/credentials/route.test.ts +++ b/apps/sim/app/api/v2/credentials/route.test.ts @@ -288,11 +288,55 @@ describe('POST /api/v2/credentials', () => { authMethod: undefined, privateKey: undefined, username: undefined, + tenancyOcid: undefined, + userOcid: undefined, + fingerprint: undefined, + privateKeyPassphrase: undefined, + region: undefined, }, request, }) }) + it('forwards OCI credential fields from the write-only credentials envelope', async () => { + const request = new NextRequest('http://localhost:3000/api/v2/credentials', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + workspaceId: WORKSPACE_ID, + type: 'service_account', + providerId: 'oci-api-key-service-account', + credentials: JSON.stringify({ + tenancyOcid: 'ocid1.tenancy.oc1..tenant', + userOcid: 'ocid1.user.oc1..user', + fingerprint: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff', + privateKey: '-----BEGIN PRIVATE KEY-----\nkey\n-----END PRIVATE KEY-----', + privateKeyPassphrase: ' exact passphrase ', + region: 'us-ashburn-1', + }), + }), + }) + const response = await POST(request) + const body = await response.text() + + expect(response.status).toBe(201) + expect(mocks.create).toHaveBeenCalledWith({ + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + input: expect.objectContaining({ + providerId: 'oci-api-key-service-account', + tenancyOcid: 'ocid1.tenancy.oc1..tenant', + userOcid: 'ocid1.user.oc1..user', + fingerprint: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff', + privateKey: '-----BEGIN PRIVATE KEY-----\nkey\n-----END PRIVATE KEY-----', + privateKeyPassphrase: ' exact passphrase ', + region: 'us-ashburn-1', + }), + request, + }) + expect(body).not.toContain('PRIVATE KEY') + expect(body).not.toContain('exact passphrase') + }) + it('rejects an unknown service-account provider before the use case', async () => { const response = await POST( new NextRequest('http://localhost:3000/api/v2/credentials', { diff --git a/apps/sim/lib/api/contracts/credentials.test.ts b/apps/sim/lib/api/contracts/credentials.test.ts index 8c1c2a371d2..a8f14cab8da 100644 --- a/apps/sim/lib/api/contracts/credentials.test.ts +++ b/apps/sim/lib/api/contracts/credentials.test.ts @@ -3,10 +3,15 @@ */ import { describe, expect, it } from 'vitest' import { + createCredentialBodySchema, createCredentialDraftBodySchema, updateCredentialByIdBodySchema, workspaceCredentialSchema, } from '@/lib/api/contracts/credentials' +import { + v2CreateServiceAccountCredentialBodySchema, + v2UpdateCredentialBodySchema, +} from '@/lib/api/contracts/v2/credentials' const credential = { id: 'credential-1', @@ -92,3 +97,43 @@ describe('createCredentialDraftBodySchema OAuth client configuration', () => { ).toBe(false) }) }) + +describe('OCI API-key credential fields', () => { + const fields = { + tenancyOcid: 'ocid1.tenancy.oc1..tenant', + userOcid: 'ocid1.user.oc1..user', + fingerprint: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff', + privateKey: '-----BEGIN PRIVATE KEY-----\nkey\n-----END PRIVATE KEY-----', + privateKeyPassphrase: ' exact passphrase ', + region: 'us-ashburn-1', + } + + it('accepts the stable web create field names and preserves the passphrase exactly', () => { + const parsed = createCredentialBodySchema.parse({ + workspaceId: '11111111-2222-4333-8444-555555555555', + type: 'service_account', + providerId: 'oci-api-key-service-account', + ...fields, + }) + + expect(parsed.privateKeyPassphrase).toBe(' exact passphrase ') + expect(parsed).toMatchObject(fields) + }) + + it('accepts the same fields in the V2 write-only envelope', () => { + const parsed = v2CreateServiceAccountCredentialBodySchema.parse({ + workspaceId: '11111111-2222-4333-8444-555555555555', + type: 'service_account', + providerId: 'oci-api-key-service-account', + credentials: JSON.stringify(fields), + }) + + expect(parsed.credentials).toMatchObject(fields) + }) + + it('accepts an omitted passphrase on rotation as an unencrypted replacement key', () => { + const { privateKeyPassphrase: _omitted, ...replacement } = fields + expect(v2UpdateCredentialBodySchema.parse(replacement)).toEqual(replacement) + expect(updateCredentialByIdBodySchema.parse(replacement)).toEqual(replacement) + }) +}) diff --git a/apps/sim/lib/credentials/application/provider-catalog.test.ts b/apps/sim/lib/credentials/application/provider-catalog.test.ts index f76c2c097ef..7d86f9e5b4a 100644 --- a/apps/sim/lib/credentials/application/provider-catalog.test.ts +++ b/apps/sim/lib/credentials/application/provider-catalog.test.ts @@ -287,6 +287,46 @@ describe('listCredentialProviderCatalog', () => { }) }) + it('publishes the OCI setup contract but keeps it unavailable without product metadata', async () => { + mocks.getAllOAuthServices.mockReturnValue([ + { + serviceId: 'oci', + providerId: 'oci-api-key-service-account', + serviceAccountProviderId: 'oci-api-key-service-account', + name: 'Oracle Cloud Infrastructure', + description: 'Connect to Oracle Cloud Infrastructure services.', + baseProvider: 'oci', + authType: 'service_account', + }, + ]) + mocks.createVisibility.mockReturnValue({ + isOAuthServiceVisible: vi.fn(), + isCredentialVisible: vi.fn().mockReturnValue(false), + }) + + const catalog = await listCredentialProviderCatalog(personalPrincipal, context) + + expect(catalog).toEqual([ + expect.objectContaining({ + type: 'service_account', + serviceId: 'oci-api-key-service-account', + providerId: 'oci-api-key-service-account', + name: 'OCI API key', + providerFamily: 'oci', + available: false, + requiresClientGeneratedCredentialId: false, + fields: [ + expect.objectContaining({ id: 'tenancyOcid', required: true, secret: false }), + expect.objectContaining({ id: 'userOcid', required: true, secret: false }), + expect.objectContaining({ id: 'fingerprint', required: true, secret: false }), + expect.objectContaining({ id: 'privateKey', required: true, secret: true }), + expect.objectContaining({ id: 'privateKeyPassphrase', required: false, secret: true }), + expect.objectContaining({ id: 'region', required: true, secret: false }), + ], + }), + ]) + }) + it('fails fast when a multi-server provider lacks complete labels', async () => { mocks.getServiceConfigByServiceId.mockImplementation((serviceId: string) => { if (serviceId === 'salesforce') { diff --git a/apps/sim/lib/credentials/oci-api-key-service-account.server.test.ts b/apps/sim/lib/credentials/oci-api-key-service-account.server.test.ts index f9dc8f0afb4..e44ebd35323 100644 --- a/apps/sim/lib/credentials/oci-api-key-service-account.server.test.ts +++ b/apps/sim/lib/credentials/oci-api-key-service-account.server.test.ts @@ -4,75 +4,35 @@ import { createHash, createPublicKey, generateKeyPairSync, type KeyObject } from 'node:crypto' import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' -const dependencies = vi.hoisted(() => { - const rows: Array<{ - type: string - providerId: string | null - encryptedServiceAccountKey: string | null - }> = [] - return { - rows, - decryptSecret: vi.fn(), - encryptSecret: vi.fn(), - sendOciRequest: vi.fn(), - select: vi.fn(() => ({ - from: vi.fn(() => ({ - where: vi.fn(() => ({ limit: vi.fn(async () => rows) })), - })), - })), - } -}) - -vi.mock('@sim/db', () => ({ db: { select: dependencies.select } })) -vi.mock('@sim/db/schema', () => ({ - credential: { - id: 'credential.id', - type: 'credential.type', - providerId: 'credential.providerId', - encryptedServiceAccountKey: 'credential.encryptedServiceAccountKey', - }, -})) -vi.mock('drizzle-orm', () => ({ eq: vi.fn(() => 'predicate') })) -vi.mock('@/lib/core/security/encryption', () => ({ - decryptSecret: dependencies.decryptSecret, - encryptSecret: dependencies.encryptSecret, +const dependencies = vi.hoisted(() => ({ + encryptSecret: vi.fn(), + verifySetup: vi.fn(), })) + +vi.mock('@/lib/core/security/encryption', () => ({ encryptSecret: dependencies.encryptSecret })) vi.mock('@/lib/internal/oci/client.server', () => ({ - sendOciRequest: dependencies.sendOciRequest, + verifyOciApiKeyCredentialForSetup: dependencies.verifySetup, })) import { - buildOciApiKeyServiceAccountSecret, - loadOciApiKeyCredential, - normalizeOciFingerprint, OciCredentialVerificationError, - parseOciApiKeyServiceAccountSecret, - serializeOciApiKeyServiceAccountSecret, verifyAndEncryptOciApiKeyCredential, - verifyOciApiKeyCredential, } from '@/lib/credentials/oci-api-key-service-account.server' -import type { OciRequestResult } from '@/lib/internal/oci/client.server' -import { OciRequestError } from '@/lib/internal/oci/errors' +import { OciClientError } from '@/lib/internal/oci/errors' import { OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID, OCI_API_KEY_SERVICE_ACCOUNT_SECRET_TYPE, } from '@/lib/oauth/types' -const TENANCY_ID = 'ocid1.tenancy.oc1..aaaaaaaafoundationtenant' -const USER_ID = 'ocid1.user.oc1..aaaaaaaafoundationuser' +const TENANCY_OCID = 'ocid1.tenancy.oc1..aaaaaaaafoundationtenant' +const USER_OCID = 'ocid1.user.oc1..aaaaaaaafoundationuser' function fingerprintForKey(privateKey: KeyObject): string { const der = createPublicKey(privateKey).export({ format: 'der', type: 'spki' }) return createHash('md5').update(der).digest('hex').match(/.{2}/g)!.join(':') } -function responseResult(body: string): OciRequestResult { - return { - response: { text: vi.fn().mockResolvedValue(body) } as unknown as OciRequestResult['response'], - } -} - -describe('OCI API-key credential foundation', () => { +describe('OCI API-key credential setup', () => { let privateKeyObject: KeyObject let privateKey: string let fingerprint: string @@ -94,264 +54,143 @@ describe('OCI API-key credential foundation', () => { }) beforeEach(() => { - dependencies.rows.splice(0) - dependencies.decryptSecret.mockReset() - dependencies.encryptSecret.mockReset() - dependencies.sendOciRequest.mockReset() - dependencies.select.mockClear() + vi.clearAllMocks() + dependencies.verifySetup.mockResolvedValue(new TextEncoder().encode('"namespace"')) + dependencies.encryptSecret.mockResolvedValue({ encrypted: 'ciphertext', iv: 'iv' }) }) function fields(overrides: Record = {}) { return { - tenancyId: TENANCY_ID, - userId: USER_ID, + tenancyOcid: TENANCY_OCID, + userOcid: USER_OCID, fingerprint, privateKey, - defaultRegion: 'us-ashburn-1', + region: 'us-ashburn-1', ...overrides, } } - it('builds a normalized, versioned, provider-bound user-principal secret', () => { - const secret = buildOciApiKeyServiceAccountSecret( - fields({ fingerprint: fingerprint.toUpperCase().replaceAll(':', ' ') }) - ) + it('normalizes stable external fields and encrypts only after GetNamespace succeeds', async () => { + await expect( + verifyAndEncryptOciApiKeyCredential( + fields({ + tenancyOcid: ` ${TENANCY_OCID} `, + userOcid: ` ${USER_OCID} `, + fingerprint: fingerprint.toUpperCase().replaceAll(':', ' '), + privateKey: privateKey.replaceAll('\n', '\r\n'), + region: ' US-ASHBURN-1 ', + }) + ) + ).resolves.toEqual({ encryptedServiceAccountKey: 'ciphertext', userOcid: USER_OCID }) + + const serialized = dependencies.verifySetup.mock.calls[0][0] + const secret = JSON.parse(serialized) expect(secret).toEqual({ type: OCI_API_KEY_SERVICE_ACCOUNT_SECRET_TYPE, providerId: OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID, - tenancyId: TENANCY_ID, - userId: USER_ID, + tenancyOcid: TENANCY_OCID, + userOcid: USER_OCID, fingerprint, privateKey, - defaultRegion: 'us-ashburn-1', - metadata: { principalKind: 'user', principalId: USER_ID }, + region: 'us-ashburn-1', + metadata: { principalKind: 'user', principalId: USER_OCID }, }) - expect(secret).not.toHaveProperty('compartmentId') - expect(secret).not.toHaveProperty('namespace') - expect(secret).not.toHaveProperty('endpoint') - expect(secret).not.toHaveProperty('realm') + expect(dependencies.encryptSecret).toHaveBeenCalledWith(serialized) + expect(dependencies.verifySetup.mock.invocationCallOrder[0]).toBeLessThan( + dependencies.encryptSecret.mock.invocationCallOrder[0] + ) }) - it('accepts encrypted RSA PEM only with the exact passphrase', () => { - expect( - buildOciApiKeyServiceAccountSecret(fields({ privateKey: encryptedPrivateKey, passphrase })) - .passphrase - ).toBe(passphrase) - expect(() => - buildOciApiKeyServiceAccountSecret(fields({ privateKey: encryptedPrivateKey })) - ).toThrow('private key or passphrase') - expect(() => - buildOciApiKeyServiceAccountSecret( - fields({ privateKey: encryptedPrivateKey, passphrase: passphrase.trim() }) + it('accepts encrypted RSA keys only with the exact preserved passphrase', async () => { + await verifyAndEncryptOciApiKeyCredential( + fields({ privateKey: encryptedPrivateKey, privateKeyPassphrase: passphrase }) + ) + expect(JSON.parse(dependencies.verifySetup.mock.calls[0][0]).privateKeyPassphrase).toBe( + passphrase + ) + + await expect( + verifyAndEncryptOciApiKeyCredential(fields({ privateKey: encryptedPrivateKey })) + ).rejects.toThrow('private key or passphrase') + await expect( + verifyAndEncryptOciApiKeyCredential( + fields({ privateKey: encryptedPrivateKey, privateKeyPassphrase: passphrase.trim() }) ) - ).toThrow('private key or passphrase') + ).rejects.toThrow('private key or passphrase') }) - it('rejects malformed, non-RSA, and undersized private keys', () => { - expect(() => buildOciApiKeyServiceAccountSecret(fields({ privateKey: 'not a key' }))).toThrow( - 'PEM encoded' - ) + it('rejects malformed, non-RSA, and undersized keys before network or encryption', async () => { const ecKey = generateKeyPairSync('ec', { namedCurve: 'prime256v1' }).privateKey - expect(() => - buildOciApiKeyServiceAccountSecret( - fields({ - privateKey: ecKey.export({ format: 'pem', type: 'pkcs8' }).toString(), - fingerprint: fingerprintForKey(ecKey), - }) - ) - ).toThrow('must use RSA') const smallKey = generateKeyPairSync('rsa', { modulusLength: 1024 }).privateKey - expect(() => - buildOciApiKeyServiceAccountSecret( - fields({ - privateKey: smallKey.export({ format: 'pem', type: 'pkcs8' }).toString(), - fingerprint: fingerprintForKey(smallKey), - }) - ) - ).toThrow('at least 2048 bits') + const cases = [ + fields({ privateKey: 'not a key' }), + fields({ + privateKey: ecKey.export({ format: 'pem', type: 'pkcs8' }).toString(), + fingerprint: fingerprintForKey(ecKey), + }), + fields({ + privateKey: smallKey.export({ format: 'pem', type: 'pkcs8' }).toString(), + fingerprint: fingerprintForKey(smallKey), + }), + ] + for (const invalid of cases) { + await expect(verifyAndEncryptOciApiKeyCredential(invalid)).rejects.toThrow() + } + expect(dependencies.verifySetup).not.toHaveBeenCalled() + expect(dependencies.encryptSecret).not.toHaveBeenCalled() }) - it('normalizes fingerprints and compares them to the key', () => { - expect(normalizeOciFingerprint(` ${fingerprint.toUpperCase()} `)).toBe(fingerprint) - expect(normalizeOciFingerprint(fingerprint.replaceAll(':', ''))).toBe(fingerprint) - expect(() => normalizeOciFingerprint('aa:bb')).toThrow('16 MD5 bytes') - expect(() => - buildOciApiKeyServiceAccountSecret( - fields({ fingerprint: '00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00' }) - ) - ).toThrow('does not match') + it('validates fingerprint, OCID types and realms, regions, controls, and size limits locally', async () => { + const invalidCases = [ + fields({ fingerprint: '00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00' }), + fields({ tenancyOcid: USER_OCID }), + fields({ userOcid: 'ocid1.user.oc2..aaaaaaaafoundationuser' }), + fields({ region: 'us-gov-ashburn-1' }), + fields({ region: 'moon-base-1' }), + fields({ userOcid: `${USER_OCID}\n` }), + fields({ privateKey: `${privateKey}\u0000` }), + fields({ privateKeyPassphrase: 'x'.repeat(4097) }), + fields({ tenancyOcid: `ocid1.tenancy.oc1..${'a'.repeat(240)}` }), + ] + for (const invalid of invalidCases) { + await expect(verifyAndEncryptOciApiKeyCredential(invalid)).rejects.toThrow() + } + expect(dependencies.verifySetup).not.toHaveBeenCalled() + expect(dependencies.encryptSecret).not.toHaveBeenCalled() }) - it('enforces size and control-character limits', () => { - expect(() => - buildOciApiKeyServiceAccountSecret( - fields({ tenancyId: `ocid1.tenancy.oc1..${'a'.repeat(240)}` }) - ) - ).toThrow('tenancy OCID') - expect(() => buildOciApiKeyServiceAccountSecret(fields({ userId: `${USER_ID}\n` }))).toThrow( - 'user OCID' + it('maps authentication, malformed-response, and transient failures without leaking details', async () => { + dependencies.verifySetup.mockRejectedValueOnce( + new OciClientError('request_failed', { status: 401 }) ) - expect(() => - buildOciApiKeyServiceAccountSecret(fields({ privateKey: `${privateKey}\u0000` })) - ).toThrow('private key') - expect(() => - buildOciApiKeyServiceAccountSecret(fields({ passphrase: 'x'.repeat(4097) })) - ).toThrow('passphrase') - expect(() => buildOciApiKeyServiceAccountSecret(fields({ passphrase: 'line\nbreak' }))).toThrow( - 'passphrase' + await expect(verifyAndEncryptOciApiKeyCredential(fields())).rejects.toEqual( + new OciCredentialVerificationError('invalid_credentials') ) - }) - it('enforces OCID resource type, realm matching, and region membership', () => { - expect(() => buildOciApiKeyServiceAccountSecret(fields({ tenancyId: USER_ID }))).toThrow( - 'wrong structure or resource type' + dependencies.verifySetup.mockResolvedValueOnce(new TextEncoder().encode('{"secret":"echo"}')) + await expect(verifyAndEncryptOciApiKeyCredential(fields())).rejects.toEqual( + new OciCredentialVerificationError('invalid_response') ) - expect(() => - buildOciApiKeyServiceAccountSecret( - fields({ userId: 'ocid1.user.oc2..aaaaaaaafoundationuser' }) - ) - ).toThrow('share a realm') - expect(() => - buildOciApiKeyServiceAccountSecret(fields({ defaultRegion: 'unknown-region-1' })) - ).toThrow('not recognized') - expect(() => - buildOciApiKeyServiceAccountSecret(fields({ defaultRegion: 'us-gov-ashburn-1' })) - ).toThrow('credential realm') - expect(() => - buildOciApiKeyServiceAccountSecret( - fields({ - tenancyId: 'ocid1.tenancy.oc99..aaaaaaaafoundationtenant', - userId: 'ocid1.user.oc99..aaaaaaaafoundationuser', - }) - ) - ).toThrow('credential realm') - }) - it('strictly parses only canonical version-one secrets', () => { - const secret = buildOciApiKeyServiceAccountSecret(fields()) - const serialized = serializeOciApiKeyServiceAccountSecret(secret) - expect(parseOciApiKeyServiceAccountSecret(serialized)).toEqual(secret) - expect(() => - parseOciApiKeyServiceAccountSecret(JSON.stringify({ ...secret, compartmentId: TENANCY_ID })) - ).toThrow('malformed') - expect(() => - parseOciApiKeyServiceAccountSecret( - JSON.stringify({ ...secret, providerId: 'another-provider' }) - ) - ).toThrow('malformed') - expect(() => - parseOciApiKeyServiceAccountSecret( - JSON.stringify({ - ...secret, - metadata: { principalKind: 'tenant', principalId: TENANCY_ID }, - }) - ) - ).toThrow('malformed') - expect(() => - parseOciApiKeyServiceAccountSecret( - JSON.stringify({ ...secret, defaultRegion: ' US-ASHBURN-1 ' }) - ) - ).toThrow('malformed') - expect(() => - parseOciApiKeyServiceAccountSecret(JSON.stringify({ ...secret, tenancyId: null })) - ).toThrow('malformed') + dependencies.verifySetup.mockRejectedValueOnce(new Error('provider echoed a secret')) + const failure = await verifyAndEncryptOciApiKeyCredential(fields()).catch( + (error: unknown) => error + ) + expect(failure).toEqual(new OciCredentialVerificationError('service_unavailable')) + expect((failure as Error).message).not.toContain('provider') + expect(dependencies.encryptSecret).not.toHaveBeenCalled() }) - it('verifies with the exact permissionless GetNamespace request and forwards bounds', async () => { - const secret = buildOciApiKeyServiceAccountSecret(fields()) + it('forwards cancellation and never encrypts an aborted verification', async () => { const controller = new AbortController() - dependencies.sendOciRequest.mockResolvedValue(responseResult('"tenant-namespace"')) - await expect(verifyOciApiKeyCredential(secret, controller.signal)).resolves.toEqual({ - namespace: 'tenant-namespace', - }) - expect(dependencies.sendOciRequest).toHaveBeenCalledWith({ - destination: expect.objectContaining({ - origin: 'https://objectstorage.us-ashburn-1.oraclecloud.com', - }), - credentials: secret, - method: 'GET', - encodedPath: '/n/', - timeout: 10_000, - maxResponseBytes: 64 * 1024, - signal: controller.signal, - serviceHeaders: { accept: 'application/json' }, - }) - expect(dependencies.sendOciRequest.mock.calls[0][0]).not.toHaveProperty('queryPairs') - expect(dependencies.sendOciRequest.mock.calls[0][0]).not.toHaveProperty('compartmentId') - }) - - it('maps authentication, malformed-response, and transient failures to secret-safe errors', async () => { - const secret = buildOciApiKeyServiceAccountSecret(fields({ passphrase: 'very-secret' })) - const cases = [ - { - failure: new OciRequestError({ - status: 401, - message: `echo ${privateKey} very-secret`, - }), - code: 'invalid_credentials', - }, - { failure: responseResult('{malformed'), code: 'invalid_response' }, - { failure: new Error(`temporary ${privateKey} very-secret`), code: 'service_unavailable' }, - ] as const - for (const testCase of cases) { - if (testCase.failure instanceof Error) { - dependencies.sendOciRequest.mockRejectedValueOnce(testCase.failure) - } else { - dependencies.sendOciRequest.mockResolvedValueOnce(testCase.failure) - } - const failure = await verifyOciApiKeyCredential(secret).catch((error: unknown) => error) - expect(failure).toBeInstanceOf(OciCredentialVerificationError) - expect((failure as OciCredentialVerificationError).code).toBe(testCase.code) - expect((failure as Error).message).not.toContain('very-secret') - expect((failure as Error).message).not.toContain('BEGIN PRIVATE KEY') - } - }) - - it('encrypts only after local validation and remote verification succeed', async () => { - const order: string[] = [] - dependencies.sendOciRequest.mockImplementation(async () => { - order.push('verify') - return responseResult('"namespace"') + const reason = new DOMException('canceled', 'AbortError') + dependencies.verifySetup.mockImplementationOnce(async (_secret, signal: AbortSignal) => { + controller.abort(reason) + throw signal.reason }) - dependencies.encryptSecret.mockImplementation(async () => { - order.push('encrypt') - return { encrypted: 'ciphertext', iv: 'iv' } - }) - await expect(verifyAndEncryptOciApiKeyCredential(fields())).resolves.toEqual({ - encryptedServiceAccountKey: 'ciphertext', - namespace: 'namespace', - }) - expect(order).toEqual(['verify', 'encrypt']) - - dependencies.sendOciRequest.mockClear() - dependencies.encryptSecret.mockClear() - await expect( - verifyAndEncryptOciApiKeyCredential(fields({ fingerprint: 'invalid' })) - ).rejects.toThrow() - expect(dependencies.sendOciRequest).not.toHaveBeenCalled() + await expect(verifyAndEncryptOciApiKeyCredential(fields(), controller.signal)).rejects.toBe( + reason + ) expect(dependencies.encryptSecret).not.toHaveBeenCalled() }) - - it('checks both outer and inner provider binding before returning decrypted material', async () => { - dependencies.rows.push({ - type: 'service_account', - providerId: 'another-provider', - encryptedServiceAccountKey: 'ciphertext', - }) - dependencies.decryptSecret.mockResolvedValue({ decrypted: 'should-not-be-read' }) - await expect(loadOciApiKeyCredential('credential-1')).rejects.toThrow('provider-mismatched') - expect(dependencies.decryptSecret).not.toHaveBeenCalled() - - const secret = buildOciApiKeyServiceAccountSecret(fields()) - dependencies.rows.splice(0) - dependencies.rows.push({ - type: 'service_account', - providerId: OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID, - encryptedServiceAccountKey: 'ciphertext', - }) - dependencies.decryptSecret.mockResolvedValueOnce({ - decrypted: JSON.stringify({ ...secret, providerId: 'another-provider' }), - }) - await expect(loadOciApiKeyCredential('credential-1')).rejects.toThrow('malformed') - }) }) diff --git a/apps/sim/lib/credentials/orchestration/index.test.ts b/apps/sim/lib/credentials/orchestration/index.test.ts index 54510cd4902..9c8bb276535 100644 --- a/apps/sim/lib/credentials/orchestration/index.test.ts +++ b/apps/sim/lib/credentials/orchestration/index.test.ts @@ -160,6 +160,58 @@ describe('performUpdateCredential — service-account secret rotation', () => { expect(result.previousDisplayName).toBe(OLD_EMAIL) }) + it('requires the complete OCI tuple and treats an omitted passphrase as clearing it', async () => { + mockCredential({ + providerId: 'oci-api-key-service-account', + displayName: 'OCI production signer', + }) + mockVerifyAndBuildServiceAccountSecret.mockResolvedValue({ + providerId: 'oci-api-key-service-account', + encryptedServiceAccountKey: 'new-oci-cipher', + displayName: 'ocid1.user.oc1..replacement', + auditMetadata: { + principalKind: 'user', + principalId: 'ocid1.user.oc1..replacement', + }, + principal: { kind: 'user', id: 'ocid1.user.oc1..replacement' }, + }) + + const incomplete = await performUpdateCredential({ + credentialId: 'cred-1', + userId: 'user-1', + privateKey: 'replacement-key', + }) + expect(incomplete).toMatchObject({ + success: false, + errorCode: 'validation', + error: expect.stringContaining('complete signing tuple'), + }) + expect(mockVerifyAndBuildServiceAccountSecret).not.toHaveBeenCalled() + + const complete = await performUpdateCredential({ + credentialId: 'cred-1', + userId: 'user-1', + tenancyOcid: 'ocid1.tenancy.oc1..tenant', + userOcid: 'ocid1.user.oc1..replacement', + fingerprint: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff', + privateKey: 'replacement-key', + region: 'us-ashburn-1', + }) + expect(complete.success).toBe(true) + expect(mockVerifyAndBuildServiceAccountSecret).toHaveBeenCalledWith( + 'oci-api-key-service-account', + expect.objectContaining({ + tenancyOcid: 'ocid1.tenancy.oc1..tenant', + userOcid: 'ocid1.user.oc1..replacement', + fingerprint: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff', + privateKey: 'replacement-key', + privateKeyPassphrase: undefined, + region: 'us-ashburn-1', + }) + ) + expect(updatePayload().encryptedServiceAccountKey).toBe('new-oci-cipher') + }) + it('keeps a label the user typed instead of the derived identity', async () => { mockCredential({ displayName: 'Prod billing exporter' }) mockStoredBlob({ type: 'service_account', client_email: OLD_EMAIL }) diff --git a/apps/sim/lib/credentials/service-account-provider-ids.test.ts b/apps/sim/lib/credentials/service-account-provider-ids.test.ts index 19fa62966df..cdfb50cae04 100644 --- a/apps/sim/lib/credentials/service-account-provider-ids.test.ts +++ b/apps/sim/lib/credentials/service-account-provider-ids.test.ts @@ -16,6 +16,7 @@ describe('isServiceAccountProviderId', () => { expect(isServiceAccountProviderId('notion-service-account')).toBe(true) expect(isServiceAccountProviderId('salesforce-service-account')).toBe(true) expect(isServiceAccountProviderId('netsuite-service-account')).toBe(true) + expect(isServiceAccountProviderId('oci-api-key-service-account')).toBe(true) }) it('is case- and whitespace-insensitive', () => { @@ -39,6 +40,7 @@ describe('getServiceAccountGatingBlockType', () => { expect(getServiceAccountGatingBlockType('notion-service-account')).toBeNull() expect(getServiceAccountGatingBlockType('google-service-account')).toBeNull() expect(getServiceAccountGatingBlockType('salesforce-service-account')).toBeNull() + expect(getServiceAccountGatingBlockType('oci-api-key-service-account')).toBeNull() }) }) @@ -63,5 +65,6 @@ describe('getServiceAccountConnectNoun', () => { // token/client descriptor, so they read as a plain "service account". expect(getServiceAccountConnectNoun('google-service-account')).toBe('service account') expect(getServiceAccountConnectNoun('atlassian-service-account')).toBe('service account') + expect(getServiceAccountConnectNoun('oci-api-key-service-account')).toBe('service account') }) }) diff --git a/apps/sim/lib/credentials/service-account-secret.test.ts b/apps/sim/lib/credentials/service-account-secret.test.ts index fd11efb6b33..40f76285b99 100644 --- a/apps/sim/lib/credentials/service-account-secret.test.ts +++ b/apps/sim/lib/credentials/service-account-secret.test.ts @@ -9,6 +9,7 @@ const { mockValidateAtlassian, mockNormalizeDomain, mockClientCredentialMinter, + mockVerifyAndEncryptOci, } = vi.hoisted(() => ({ // Identity encryption so tests can read back the JSON blob. mockEncryptSecret: vi.fn(async (value: string) => ({ encrypted: value })), @@ -16,6 +17,7 @@ const { mockValidateAtlassian: vi.fn(), mockNormalizeDomain: vi.fn((raw: string) => raw.trim().toLowerCase()), mockClientCredentialMinter: vi.fn(), + mockVerifyAndEncryptOci: vi.fn(), })) vi.mock('@/lib/core/security/encryption', () => ({ encryptSecret: mockEncryptSecret })) @@ -24,6 +26,14 @@ vi.mock('@/lib/credentials/atlassian-service-account', () => ({ validateAtlassianServiceAccount: mockValidateAtlassian, normalizeAtlassianDomain: mockNormalizeDomain, })) +vi.mock('@/lib/credentials/oci-api-key-service-account.server', () => ({ + OciCredentialVerificationError: class OciCredentialVerificationError extends Error { + constructor(readonly code: string) { + super(code) + } + }, + verifyAndEncryptOciApiKeyCredential: mockVerifyAndEncryptOci, +})) vi.mock('@/lib/api/contracts/credentials', () => ({ serviceAccountJsonSchema: { safeParse: (value: string) => { @@ -163,6 +173,74 @@ describe('verifyAndBuildServiceAccountSecret', () => { expect(result.providerId).toBe('google-service-account') }) + it('verifies and stores an OCI API-key credential with stable external fields', async () => { + mockVerifyAndEncryptOci.mockResolvedValue({ + encryptedServiceAccountKey: 'oci-ciphertext', + userOcid: 'ocid1.user.oc1..principal', + }) + + const result = await verifyAndBuildServiceAccountSecret('oci-api-key-service-account', { + tenancyOcid: 'ocid1.tenancy.oc1..tenant', + userOcid: 'ocid1.user.oc1..principal', + fingerprint: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff', + privateKey: '-----BEGIN PRIVATE KEY-----\nkey\n-----END PRIVATE KEY-----', + privateKeyPassphrase: ' preserved exactly ', + region: 'us-ashburn-1', + }) + + expect(mockVerifyAndEncryptOci).toHaveBeenCalledWith({ + tenancyOcid: 'ocid1.tenancy.oc1..tenant', + userOcid: 'ocid1.user.oc1..principal', + fingerprint: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff', + privateKey: '-----BEGIN PRIVATE KEY-----\nkey\n-----END PRIVATE KEY-----', + privateKeyPassphrase: ' preserved exactly ', + region: 'us-ashburn-1', + }) + expect(result).toEqual({ + providerId: 'oci-api-key-service-account', + encryptedServiceAccountKey: 'oci-ciphertext', + displayName: 'ocid1.user.oc1..principal', + auditMetadata: { + principalKind: 'user', + principalId: 'ocid1.user.oc1..principal', + }, + principal: { kind: 'user', id: 'ocid1.user.oc1..principal' }, + }) + }) + + it('requires the complete OCI signing tuple before verification', async () => { + await expect( + verifyAndBuildServiceAccountSecret('oci-api-key-service-account', { + tenancyOcid: 'ocid1.tenancy.oc1..tenant', + }) + ).rejects.toThrow('tenancyOcid, userOcid, fingerprint, privateKey, and region are required') + expect(mockVerifyAndEncryptOci).not.toHaveBeenCalled() + }) + + it('classifies OCI verification outages without exposing provider details', async () => { + const { OciCredentialVerificationError } = await import( + '@/lib/credentials/oci-api-key-service-account.server' + ) + mockVerifyAndEncryptOci.mockRejectedValue( + new OciCredentialVerificationError('service_unavailable') + ) + + const failure = await verifyAndBuildServiceAccountSecret('oci-api-key-service-account', { + tenancyOcid: 'ocid1.tenancy.oc1..tenant', + userOcid: 'ocid1.user.oc1..principal', + fingerprint: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff', + privateKey: 'provider-secret-key', + region: 'us-ashburn-1', + }).catch((error: unknown) => error) + + expect(failure).toBeInstanceOf(ServiceAccountSecretError) + expect(failure).toMatchObject({ + message: 'OCI is temporarily unavailable for credential verification', + providerErrorCode: 'provider_unavailable', + }) + expect(JSON.stringify(failure)).not.toContain('provider-secret-key') + }) + it('rejects an unknown non-empty providerId instead of persisting it as Google', async () => { const json = JSON.stringify({ type: 'service_account', client_email: 'svc@proj.iam' }) await expect( diff --git a/apps/sim/lib/internal/oci/client.server.test.ts b/apps/sim/lib/internal/oci/client.server.test.ts index 4f1917771c4..70579b62066 100644 --- a/apps/sim/lib/internal/oci/client.server.test.ts +++ b/apps/sim/lib/internal/oci/client.server.test.ts @@ -1,684 +1,809 @@ /** * @vitest-environment node */ -import { generateKeyPairSync } from 'node:crypto' -import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { createPublicKey, verify } from 'node:crypto' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + backoff: vi.fn(), + decryptSecret: vi.fn(), + predicates: undefined as unknown, + rows: [] as { encryptedServiceAccountKey: string | null }[], + secureFetch: vi.fn(), +})) + +vi.mock('@sim/db', () => ({ + db: { + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn((predicate: unknown) => { + mocks.predicates = predicate + return { limit: vi.fn(async () => mocks.rows) } + }), + })), + })), + }, +})) + +vi.mock('@sim/db/schema', () => ({ + credential: { + encryptedServiceAccountKey: 'credential.encryptedServiceAccountKey', + id: 'credential.id', + providerId: 'credential.providerId', + type: 'credential.type', + workspaceId: 'credential.workspaceId', + }, +})) + +vi.mock('drizzle-orm', () => ({ + and: vi.fn((...predicates: unknown[]) => predicates), + eq: vi.fn((field: unknown, value: unknown) => ({ field, value })), +})) -const secureFetchMock = vi.hoisted(() => vi.fn()) +vi.mock('@/lib/core/security/encryption', () => ({ decryptSecret: mocks.decryptSecret })) vi.mock('@/lib/core/security/input-validation.server', () => ({ DEFAULT_MAX_RESPONSE_BYTES: 100 * 1024 * 1024, - secureFetchWithValidation: secureFetchMock, + secureFetchWithValidation: mocks.secureFetch, +})) + +vi.mock('@sim/utils/retry', () => ({ + backoffWithJitter: mocks.backoff, + parseRetryAfter: vi.fn(() => null), +})) + +vi.mock('@/lib/oauth/utils', () => ({ + getServiceConfigByServiceId: vi.fn((serviceId: string) => + serviceId === 'oci' + ? { serviceAccountProviderId: 'oci-api-key-service-account' } + : serviceId === 'slack' + ? { serviceAccountProviderId: 'slack-custom-bot' } + : null + ), })) import { - buildOciRequestUrl, - sendOciRequest, - serializeOciQueryPairs, + createOciClient, + type OciAuthenticatedResponse, + type OciClient, + type OciRequest, } from '@/lib/internal/oci/client.server' -import { getOciRegion, objectStorageOciDestination } from '@/lib/internal/oci/endpoints' -import { OciRequestError } from '@/lib/internal/oci/errors' -import type { OciSigningCredentials } from '@/lib/internal/oci/signing.server' +import { + createOciDiscoveredEndpointPolicy, + createOciStaticEndpointPolicy, +} from '@/lib/internal/oci/endpoints' +import { OciClientError } from '@/lib/internal/oci/errors' +import { OCI_SERVICE_ID } from '@/lib/oauth/types' + +// Fixed test material. The expected signatures were generated independently with +// OpenSSL 3 against Oracle's Request Signatures specification (retrieved 2026-09-03): +// https://docs.oracle.com/en-us/iaas/Content/API/Concepts/signingrequests.htm +// The canonical header order is cross-checked against oci-common 2.140.0. +const PRIVATE_KEY = `-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDGu21M7TuK4Jr6 +s8luoTzVRltBhYM078Z0JNpg3/uwqLIYtmNFDLg9AJ4NY9piBfZoE4b9EhrVzwkW ++wIWdSflJPfnlWFD7nLBk+n69dyU1wwUuEw0PYZOliFvCmlegg9qE+vZK13o5e1m +08ZEq7oxfArlHH3NZXuwoZJiraP/mtGurDrcAJLUKuTMfEp+zUOUdmupeZjmNWj9 +B8xbgRoQ3vQVk+7q+ltMvsUdZB2La+IEhTg6PMCrSsRV0v/xqJiSQ34iPkxq2LrD +AUKxypwmX8X0c2VWYQh/ho3x3pT5XPxC3x/plkM8DxC7Ejjg1qa0jyl0JLzMWNnh +V79UvkKRAgMBAAECggEAO3ueG4hmagsQWDm38QUR0ERezB3KR+382IavVo+0JgxY +Qk1VKTXFb3zf0eIxW2WtezldDiJ9JcHyVo6K8W3foxaNnSN5GXwlnQtI3XT5sRMs +6oa/SGOh76PAHhxfrYoAUx/jV/1C/pLTnBOHJMbB1E3sdOcyQGg/vX6e8ipHDBoj +24tljd5fvmDWkR/WYHwjn2xaY8Ee3/EfIoBw5r+WrXLjpj5FuGUo+pxyqbSI2qE/ +mpOMEi/+KprpUU8N5e33+cihyrneAKLyqyxS7NPWmbc5+ut0g4uzIu1NmyAhfa2o +c1MbQqh+C2R96tbhPAJQHeRClV1YKUpOiXj6EvpmAQKBgQDmEoNkMSWfX0gJMOdM +8kh641t3KBqyyGt3kx2xTaeybq8MFilQCahSTjfndkT8tlW1eRh2BiMUvvdSpCPM +wRH7BGW4h8J6ALmMnj0nsl8ebJc7g0hzacRG+SAVD8IbQqIzc0rY/DUfuoIuL5Ce +R0l9p85r2ZBGNrnM9dIUkfNj/QKBgQDdIMNXzKGPRUUkdN5kskfCEV3a0geVFaU0 +ZOiZf6TRidcl5RTaTcJbRJ2pXsealDlURdmrk8lGgy0uTE181Zn71bBPKjN1xmct +H8SMQvxcI62OYaUbEpzgp83TZXtRpqmVA2v+0BjhrjPPjVKsT5YwkHRPb5DyHOW8 +D8HB/dO7JQKBgQDbW6lknKtHUZwoDzVpGtPaPu2VJWqXLRmxr1WvF+Ac8wT43CRV +iG+w0ZzhldTesaX0WVnmJaHLBOxgIdl0Ply7XQzzLJVSp2BB3xllwN6J7nUeq+Qn +Dh+yn5JkIlsqjJSDw5gIXCb2cmfuSzFyh3tdT+Iy2AODvmfWMEY1kJZjrQKBgDUO +wHBXtEg5Ob7mn9oPgPJK0ndHv/QArpQkxj7WhsiUR2BbWCaNU94sV5wlFsW7XQog +fHsTyc62eOfL/Se/5OOtQVGtcY2H3ofQQIvbIsxE70bjnQci7ytkeBmKFw3fbH9J +w+bvLZkxAFODuFuJ+SKL9qx8u42sa181dKtEaUJVAoGBALuFS1q/ihZw8M5AoofY +llBvP7/pHwT8XR2gWl5sZFOt6kvrMQqcI3u/9BkVR9au1I2K7xJOQmt9KEL4HkgP +6cqql61lZNv8GgYlJPu8ipN0IUxf1V7K+9xw0t1am57WATCW+bqkfyvYoBXhLwx6 +7z8JESybW/3kkmWIOy5WHvzv +-----END PRIVATE KEY----- +` + +const SECRET = JSON.stringify({ + type: 'oci_api_signing_key_v1', + providerId: 'oci-api-key-service-account', + tenancyOcid: 'ocid1.tenancy.oc1..aaaaaaaafixedvector', + userOcid: 'ocid1.user.oc1..aaaaaaaafixedvector', + fingerprint: '25:53:22:62:aa:db:ff:ef:f5:77:08:d1:a2:ed:8b:e6', + privateKey: PRIVATE_KEY, + region: 'us-ashburn-1', + metadata: { + principalKind: 'user', + principalId: 'ocid1.user.oc1..aaaaaaaafixedvector', + }, +}) + +const STATIC_POLICY = createOciStaticEndpointPolicy({ + serviceId: OCI_SERVICE_ID, + serviceName: 'identity', +}) function secureResponse(params: { - ok: boolean - status: number - body?: string - responseBody?: ReadableStream | null - opcRequestId?: string + status?: number + body?: Uint8Array | string + headers?: Record }) { + const bytes = + typeof params.body === 'string' + ? new TextEncoder().encode(params.body) + : (params.body ?? new Uint8Array()) return { - ok: params.ok, - status: params.status, + ok: (params.status ?? 200) >= 200 && (params.status ?? 200) < 300, + status: params.status ?? 200, statusText: '', - headers: { - get: (name: string) => - name.toLowerCase() === 'opc-request-id' ? (params.opcRequestId ?? null) : null, - }, - body: params.responseBody ?? null, - text: vi.fn().mockResolvedValue(params.body ?? ''), - json: vi.fn(), - arrayBuffer: vi.fn(), + headers: new Headers({ 'content-length': String(bytes.byteLength), ...params.headers }), + body: new ReadableStream({ + start(controller) { + if (bytes.byteLength > 0) controller.enqueue(bytes) + controller.close() + }, + }), + text: vi.fn(async () => Buffer.from(bytes).toString('utf8')), + json: vi.fn(async () => JSON.parse(Buffer.from(bytes).toString('utf8'))), + arrayBuffer: vi.fn(async () => bytes.buffer.slice(0)), } } -describe('OCI request client', () => { - let credentials: OciSigningCredentials - const destination = objectStorageOciDestination(getOciRegion('us-ashburn-1')) - - beforeAll(() => { - const pair = generateKeyPairSync('rsa', { modulusLength: 2048 }) - credentials = { - tenancyId: 'ocid1.tenancy.oc1..clienttest', - userId: 'ocid1.user.oc1..clienttest', - fingerprint: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff', - privateKey: pair.privateKey.export({ format: 'pem', type: 'pkcs8' }).toString(), - passphrase: 'client-secret-passphrase', - } +async function createPreparedClient(params: { region?: string } = {}): Promise<{ + client: OciClient + endpoint: Awaited> +}> { + const client = await createOciClient({ + credentialId: 'credential-authoritative', + workspaceId: 'workspace-trusted', + serviceId: OCI_SERVICE_ID, + ...params, }) + const endpoint = await client.prepareStaticEndpoint(STATIC_POLICY) + return { client, endpoint } +} +function authorizationFromLastRequest(): string { + const options = mocks.secureFetch.mock.calls.at(-1)?.[1] as { headers: Record } + return options.headers.authorization +} + +describe('credential-bound OCI client', () => { beforeEach(() => { - secureFetchMock.mockReset() - secureFetchMock.mockResolvedValue(secureResponse({ ok: true, status: 200 })) + mocks.predicates = undefined + mocks.rows = [{ encryptedServiceAccountKey: 'encrypted-secret' }] + mocks.decryptSecret.mockReset().mockResolvedValue({ decrypted: SECRET }) + mocks.backoff.mockReset().mockReturnValue(0) + mocks.secureFetch.mockReset().mockResolvedValue(secureResponse({})) }) - it('serializes ordered duplicate and Unicode query pairs with RFC 3986 encoding', () => { - expect( - serializeOciQueryPairs([ - ['z', 'last'], - ['a', 'one'], - ['a', ''], - ['space', 'a b'], - ['unicode', '☃'], - ["!'()*", "!'()*"], - ]) - ).toBe('z=last&a=one&a=&space=a%20b&unicode=%E2%98%83&%21%27%28%29%2A=%21%27%28%29%2A') + afterEach(() => { + vi.useRealTimers() }) - it('transmits the exact URL, finalized body, and headers that were signed', async () => { - const body = '{"message":"héllo ☃"}' - await sendOciRequest({ - destination, - credentials, - method: 'POST', - encodedPath: '/n/tenant/b', - queryPairs: [ - ['z', 'last'], - ['a', 'one'], - ['a', ''], - ['unicode', '☃'], - ], - timeout: 12_345, - maxResponseBytes: 54_321, - serviceHeaders: { accept: 'application/json', 'opc-retry-token': 'fixed-token' }, - body, - }) + it('loads only an exact credential/workspace/type/provider row before decryption', async () => { + await createPreparedClient() - expect(secureFetchMock).toHaveBeenCalledOnce() - const [url, options, paramName] = secureFetchMock.mock.calls[0] - expect(url).toBe( - 'https://objectstorage.us-ashburn-1.oraclecloud.com/n/tenant/b?z=last&a=one&a=&unicode=%E2%98%83' - ) - expect(paramName).toBe('OCI destination') - expect(options).toMatchObject({ - method: 'POST', - body, - timeout: 12_345, - maxResponseBytes: 54_321, - maxRedirects: 0, - profile: 'configuredEndpoint', - logUrlValidationDetails: false, - }) - expect(options.headers.accept).toBe('application/json') - expect(options.headers['opc-retry-token']).toBe('fixed-token') - expect(options.headers.authorization).toContain('Signature version="1"') - expect(options.headers['content-length']).toBe(String(Buffer.byteLength(body, 'utf8'))) - expect(options.headers).not.toHaveProperty('date') + expect(mocks.predicates).toEqual([ + { field: 'credential.id', value: 'credential-authoritative' }, + { field: 'credential.workspaceId', value: 'workspace-trusted' }, + { field: 'credential.type', value: 'service_account' }, + { field: 'credential.providerId', value: 'oci-api-key-service-account' }, + ]) + expect(mocks.decryptSecret).toHaveBeenCalledOnce() }) - it('forwards cancellation and always disables redirects', async () => { - const controller = new AbortController() - await sendOciRequest({ - destination, - credentials, - method: 'GET', - encodedPath: '/n/', - timeout: 10_000, - maxResponseBytes: 65_536, - signal: controller.signal, + it.each([ + ['missing row', () => (mocks.rows = [])], + ['null secret', () => (mocks.rows = [{ encryptedServiceAccountKey: null }])], + ['decrypt failure', () => mocks.decryptSecret.mockRejectedValueOnce(new Error('ciphertext'))], + ['malformed secret', () => mocks.decryptSecret.mockResolvedValueOnce({ decrypted: '{}' })], + ])('projects %s as the same credential-unavailable failure', async (_name, arrange) => { + arrange() + const client = await createOciClient({ + credentialId: 'raw-id-is-not-authority', + workspaceId: 'wrong-or-right-workspace', + serviceId: OCI_SERVICE_ID, }) - expect(secureFetchMock.mock.calls[0][1]).toMatchObject({ - signal: controller.signal, - timeout: 10_000, - maxResponseBytes: 65_536, - maxRedirects: 0, + await expect(client.prepareStaticEndpoint(STATIC_POLICY)).rejects.toMatchObject({ + code: 'credential_unavailable', + message: 'OCI credential is unavailable', }) }) - it('returns bounded successful responses and the OCI request id without imposing a schema', async () => { - const response = secureResponse({ - ok: true, - status: 202, - body: 'service-specific bytes', - opcRequestId: 'request-123', + it('fails a registered-service mismatch before loading or network work', async () => { + await expect( + createOciClient({ + credentialId: 'credential-authoritative', + workspaceId: 'workspace-trusted', + serviceId: 'slack', + }) + ).rejects.toMatchObject({ code: 'invalid_endpoint' }) + expect(mocks.decryptSecret).not.toHaveBeenCalled() + expect(mocks.secureFetch).not.toHaveBeenCalled() + }) + + it('fails a policy/client owner mismatch before loading or network work', async () => { + const client = await createOciClient({ + credentialId: 'credential-authoritative', + workspaceId: 'workspace-trusted', + serviceId: OCI_SERVICE_ID, }) - secureFetchMock.mockResolvedValueOnce(response) - const result = await sendOciRequest({ - destination, - credentials, - method: 'GET', - encodedPath: '/n/', - timeout: 10_000, - maxResponseBytes: 65_536, + const wrongPolicy = createOciStaticEndpointPolicy({ + serviceId: 'slack', + serviceName: 'identity', + }) + await expect(client.prepareStaticEndpoint(wrongPolicy)).rejects.toMatchObject({ + code: 'invalid_endpoint', }) - expect(result).toEqual({ response, opcRequestId: 'request-123' }) - expect(response.text).not.toHaveBeenCalled() + expect(mocks.decryptSecret).not.toHaveBeenCalled() + expect(mocks.secureFetch).not.toHaveBeenCalled() }) - it('retains bounded OCI error fields and request ids while redacting echoed secrets', async () => { - const echoedUrl = 'https://objectstorage.us-ashburn-1.oraclecloud.com/n/' - secureFetchMock.mockResolvedValueOnce( - secureResponse({ - ok: false, - status: 401, - opcRequestId: 'request-401', - body: JSON.stringify({ - code: 'NotAuthenticated', - message: `provider echoed ${credentials.passphrase} ${credentials.privateKey} ${echoedUrl}\n`, - }), - }) + it('enforces realm-compatible region overrides', async () => { + await expect(createPreparedClient({ region: 'us-gov-ashburn-1' })).rejects.toMatchObject({ + code: 'invalid_endpoint', + }) + expect((await createPreparedClient({ region: 'eu-frankfurt-1' })).endpoint.origin).toBe( + 'https://identity.eu-frankfurt-1.oraclecloud.com' ) - const failure = await sendOciRequest({ - destination, - credentials, + }) + + it('matches the fixed Oracle canonical signing fixture', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-09-03T19:00:00.000Z')) + const { client, endpoint } = await createPreparedClient() + await client.request({ + endpoint, method: 'GET', - encodedPath: '/n/', - timeout: 10_000, - maxResponseBytes: 65_536, - }).catch((error: unknown) => error) - expect(failure).toBeInstanceOf(OciRequestError) - expect(failure).toMatchObject({ - status: 401, - code: 'NotAuthenticated', - opcRequestId: 'request-401', + encodedPath: '/20160918/users', + queryPairs: [ + ['limit', '10'], + ['name', 'Team X'], + ], + timeoutMs: 10_000, + maxResponseBytes: 1024, }) - expect((failure as Error).message).toContain('[REDACTED]') - expect((failure as Error).message).not.toContain('client-secret-passphrase') - expect((failure as Error).message).not.toContain('BEGIN PRIVATE KEY') - expect((failure as Error).message).not.toContain('objectstorage.us-ashburn-1') - expect((failure as Error).message.length).toBeLessThanOrEqual(1050) - }) - it('does not expose malformed response bodies or signed request details', async () => { - secureFetchMock.mockResolvedValueOnce( - secureResponse({ - ok: false, - status: 502, - opcRequestId: 'request-502', - body: `${credentials.privateKey}`, - }) + const authorization = authorizationFromLastRequest() + expect(authorization).toBe( + 'Signature version="1",keyId="ocid1.tenancy.oc1..aaaaaaaafixedvector/ocid1.user.oc1..aaaaaaaafixedvector/25:53:22:62:aa:db:ff:ef:f5:77:08:d1:a2:ed:8b:e6",algorithm="rsa-sha256",headers="x-date (request-target) host",signature="pcMhip57/dPnKl/dfg5usN7oT/illXEGUp9Oj2d9bpGb0aRMBJclgVFKRYdYXciUGPM/9vKluD5/eGPBO1Oh7w/6NCB8UX2Ejh/lw8merU1QalZ/OfHyj+wKNVOpqwQjNqettRUzSVMhCqImDnvgx8ygmVCvdc0CeLXf2ZF9iT1bYlDjOiuxOcWreN2rs1ZmfLCfal204nAjrNAvoBSgHCPVquAYnfsT2auOWP4QeHN/Hd/v7TvNqsWBFIaLCyWZOvRzpsw/ZLgLzB+jkuPTdL7l4hOZATUd7xy1QPFTJ0P1RlLHjZE1sH7hbrqVGORNXrVhA1LaArObz6GWPOOghA=="' ) - const failure = await sendOciRequest({ - destination, - credentials, - method: 'GET', - encodedPath: '/n/', - timeout: 10_000, - maxResponseBytes: 65_536, - }).catch((error: unknown) => error) - expect(failure).toBeInstanceOf(OciRequestError) - expect((failure as Error).message).toBe('OCI request failed with status 502') - expect((failure as OciRequestError).opcRequestId).toBe('request-502') + + const signature = /signature="([^"]+)"/.exec(authorization)?.[1] + expect(signature).toBeDefined() + expect( + verify( + 'RSA-SHA256', + 'x-date: Thu, 03 Sep 2026 19:00:00 GMT\n(request-target): get /20160918/users?limit=10&name=Team%20X\nhost: identity.us-ashburn-1.oraclecloud.com', + createPublicKey(PRIVATE_KEY), + Buffer.from(signature!, 'base64') + ) + ).toBe(true) }) - it('redacts authorization material embedded in a serialized JSON message', async () => { - const echoedAuthorization = 'opaque-authorization-value' - secureFetchMock.mockResolvedValueOnce( - secureResponse({ - ok: false, - status: 401, - body: JSON.stringify({ - code: 'NotAuthenticated', - message: JSON.stringify({ authorization: echoedAuthorization }), - }), - }) + it('matches the fixed Oracle body-signing fixture for an empty body', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-09-03T19:00:00.000Z')) + const { client, endpoint } = await createPreparedClient() + await client.request({ + endpoint, + method: 'POST', + encodedPath: '/20160918/users', + body: new Uint8Array(), + contentType: 'application/json', + timeoutMs: 10_000, + maxResponseBytes: 1024, + }) + + expect(authorizationFromLastRequest()).toBe( + 'Signature version="1",keyId="ocid1.tenancy.oc1..aaaaaaaafixedvector/ocid1.user.oc1..aaaaaaaafixedvector/25:53:22:62:aa:db:ff:ef:f5:77:08:d1:a2:ed:8b:e6",algorithm="rsa-sha256",headers="x-date (request-target) host content-type content-length x-content-sha256",signature="vyhrwd21evtwFet82VT1FvKEeZV+JSa3VZuS5p4Pj8K2zeU88GO+tGx/voUK9TFHijF7eG5gGS6WWc6tigrByTocbVOHpLtPNgBo2+1NbTbGHGUZIzCOR5CZ1ite74Ak43xZjyKBm+vZHrvS22leVOJe43V/HjqCxqyPn3WkKd7npqo9eFM1sibdj1h3Cmi79b5nXSPFe5KE+rnMRPTOB4nl7iFELvubg/Y7Y8w5hRYEe13w09zw9tTBdGJtZIuMoYwZYzPdZo5wbrN5WM6ylHC2euVh2PSazZZU99q55uhxiR6OaCQWLM0buytCqja8FeiEY8Iw3GuEbKUECKaM8Q=="' ) - const failure = await sendOciRequest({ - destination, - credentials, - method: 'GET', - encodedPath: '/n/', - timeout: 10_000, - maxResponseBytes: 65_536, - }).catch((error: unknown) => error) - expect(failure).toBeInstanceOf(OciRequestError) - expect((failure as Error).message).toContain('[REDACTED]') - expect((failure as Error).message).not.toContain(echoedAuthorization) + expect(mocks.secureFetch.mock.calls[0][1].headers).toMatchObject({ + 'content-length': '0', + 'content-type': 'application/json', + 'x-content-sha256': '47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=', + }) }) - it('redacts encoded credentials instead of falling through to a status-only error', async () => { - const encodedFingerprint = encodeURIComponent(credentials.fingerprint) - const escapedPassphrase = 'secret "pass"' - const encodedPassphrase = encodeURIComponent(escapedPassphrase) - secureFetchMock.mockResolvedValueOnce( - secureResponse({ - ok: false, - status: 401, - body: JSON.stringify({ - code: 'NotAuthenticated', - message: `provider echoed ${encodedFingerprint} ${encodedPassphrase}`, - }), - }) + it('preserves ordered duplicate queries and exact binary request bytes', async () => { + const { client, endpoint } = await createPreparedClient() + const body = new Uint8Array([0, 255, 1, 240, 159, 140, 131]) + await client.request({ + endpoint, + method: 'POST', + encodedPath: '/v1/%E2%98%83', + queryPairs: [ + ['z', 'last'], + ['a', ''], + ['a', " !'()*"], + ], + headers: { accept: 'application/json' }, + body, + contentType: 'application/octet-stream', + timeoutMs: 10_000, + maxResponseBytes: 1024, + }) + + const [url, options] = mocks.secureFetch.mock.calls[0] as [ + string, + { body: Uint8Array; headers: Record }, + ] + expect(url).toBe( + 'https://identity.us-ashburn-1.oraclecloud.com/v1/%E2%98%83?z=last&a=&a=%20%21%27%28%29%2A' ) - const failure = await sendOciRequest({ - destination, - credentials: { ...credentials, passphrase: escapedPassphrase }, - method: 'GET', - encodedPath: '/n/', - timeout: 10_000, - maxResponseBytes: 65_536, - }).catch((error: unknown) => error) - expect((failure as Error).message).toContain('provider echoed') - expect((failure as Error).message).toContain('[REDACTED]') - expect((failure as Error).message).not.toContain(encodedFingerprint) - expect((failure as Error).message).not.toContain(encodedPassphrase) - expect((failure as Error).message).not.toContain(escapedPassphrase) + expect([...options.body]).toEqual([...body]) + expect(options.body).not.toBe(body) + expect(options.headers).toMatchObject({ + 'content-length': '7', + 'content-type': 'application/octet-stream', + 'x-content-sha256': 'ujM2KRiewv2gytZWgW9aE6ZPWa2LOxmcemXv0wuwcrs=', + }) }) - it('redacts an encoded signed request URL instead of returning it', async () => { - const encodedRequestUrl = encodeURIComponent(`${destination.origin}/n/`) - secureFetchMock.mockResolvedValueOnce( - secureResponse({ - ok: false, - status: 401, - body: JSON.stringify({ - code: 'NotAuthenticated', - message: `provider echoed ${encodedRequestUrl}`, - }), + it.each(['GET', 'HEAD', 'DELETE'] as const)('rejects bodies for %s', async (method) => { + const { client, endpoint } = await createPreparedClient() + await expect( + client.request({ + endpoint, + method, + encodedPath: '/v1/test', + body: new Uint8Array(), + contentType: 'application/json', + timeoutMs: 10_000, + maxResponseBytes: 1024, }) - ) - const failure = await sendOciRequest({ - destination, - credentials, - method: 'GET', - encodedPath: '/n/', - timeout: 10_000, - maxResponseBytes: 65_536, - }).catch((error: unknown) => error) - expect((failure as Error).message).toContain('provider echoed') - expect((failure as Error).message).toContain('[REDACTED]') - expect((failure as Error).message).not.toContain(encodedRequestUrl) + ).rejects.toMatchObject({ code: 'invalid_request' }) }) - it('redacts caller-supplied service header values echoed by the provider', async () => { - const serviceHeaderSecret = 'opaque-service-header-secret' - secureFetchMock.mockResolvedValueOnce( - secureResponse({ - ok: false, - status: 401, - body: JSON.stringify({ - code: 'NotAuthenticated', - message: `provider echoed ${serviceHeaderSecret}`, - }), + it.each(['GET', 'HEAD', 'DELETE'] as const)( + 'sends a bodyless %s without body signing headers', + async (method) => { + const { client, endpoint } = await createPreparedClient() + await client.request({ + endpoint, + method, + encodedPath: '/v1/test', + timeoutMs: 10_000, + maxResponseBytes: 1024, }) - ) - const failure = await sendOciRequest({ - destination, - credentials, - method: 'GET', - encodedPath: '/n/', - timeout: 10_000, - maxResponseBytes: 65_536, - serviceHeaders: { 'opc-client-info': serviceHeaderSecret }, - }).catch((error: unknown) => error) - expect((failure as Error).message).toContain('[REDACTED]') - expect((failure as Error).message).not.toContain(serviceHeaderSecret) - }) + const options = mocks.secureFetch.mock.calls.at(-1)?.[1] + expect(options.method).toBe(method) + expect(options).not.toHaveProperty('body') + expect(options.headers).not.toHaveProperty('content-length') + expect(options.headers).not.toHaveProperty('x-content-sha256') + } + ) - it('redacts an echoed finalized request body from provider diagnostics', async () => { - const requestBody = 'opaque-request-body-secret' - secureFetchMock.mockResolvedValueOnce( - secureResponse({ - ok: false, - status: 400, - body: JSON.stringify({ - code: 'InvalidParameter', - message: `provider echoed ${requestBody}`, - }), + it.each(['POST', 'PUT', 'PATCH'] as const)( + 'requires an exact body and content type for %s, including empty bodies', + async (method) => { + const { client, endpoint } = await createPreparedClient() + await expect( + client.request({ + endpoint, + method, + encodedPath: '/v1/test', + timeoutMs: 10_000, + maxResponseBytes: 1024, + }) + ).rejects.toMatchObject({ code: 'invalid_request' }) + await client.request({ + endpoint, + method, + encodedPath: '/v1/test', + body: new Uint8Array(), + contentType: 'application/json', + timeoutMs: 10_000, + maxResponseBytes: 1024, }) - ) - const failure = await sendOciRequest({ - destination, - credentials, - method: 'POST', - encodedPath: '/n/', - timeout: 10_000, - maxResponseBytes: 65_536, - body: requestBody, - }).catch((error: unknown) => error) - expect((failure as Error).message).toContain('[REDACTED]') - expect((failure as Error).message).not.toContain(requestBody) + expect(mocks.secureFetch.mock.calls.at(-1)?.[1].headers['content-length']).toBe('0') + } + ) + + it.each([ + 'relative', + '//host/path', + '/double//slash', + '/query?x=1', + '/back\\slash', + '/encoded%2Fslash', + '/encoded%5Cbackslash', + '/encoded%00control', + '/encoded%1fcontrol', + '/encoded%7Fcontrol', + '/bad%2', + ])('rejects ambiguous encoded paths: %s', async (encodedPath) => { + const { client, endpoint } = await createPreparedClient() + await expect( + client.request({ + endpoint, + method: 'GET', + encodedPath, + timeoutMs: 10_000, + maxResponseBytes: 1024, + }) + ).rejects.toMatchObject({ code: 'invalid_request' }) }) - it('fails closed instead of redacting an unbounded request body', async () => { - const requestBody = 's'.repeat(65_537) - secureFetchMock.mockResolvedValueOnce( - secureResponse({ - ok: false, - status: 400, - opcRequestId: 'request-body-echo', - body: JSON.stringify({ - code: 'InvalidParameter', - message: `provider echoed ${requestBody.slice(0, 1024)}`, - }), + it('rejects signing-controlled headers', async () => { + const { client, endpoint } = await createPreparedClient() + await expect( + client.request({ + endpoint, + method: 'GET', + encodedPath: '/v1/test', + headers: { Authorization: 'forged' }, + timeoutMs: 10_000, + maxResponseBytes: 1024, }) - ) - const failure = await sendOciRequest({ - destination, - credentials, - method: 'POST', - encodedPath: '/n/', - timeout: 10_000, - maxResponseBytes: 65_536, - body: requestBody, - }).catch((error: unknown) => error) - expect((failure as Error).message).toBe('OCI request failed with status 400') - expect((failure as OciRequestError).opcRequestId).toBeUndefined() + ).rejects.toMatchObject({ code: 'invalid_request' }) }) - it('redacts a maximum-size passphrase before bounding an encoded diagnostic', async () => { - const longPassphrase = ' '.repeat(4096) - const encodedPassphrase = new URLSearchParams({ value: longPassphrase }) - .toString() - .slice('value='.length) - secureFetchMock.mockResolvedValueOnce( - secureResponse({ - ok: false, - status: 401, - body: JSON.stringify({ - code: 'NotAuthenticated', - message: `provider echoed ${encodedPassphrase}`, - }), + it('fails closed on malformed runtime request shapes', async () => { + const { client, endpoint } = await createPreparedClient() + const base = { + endpoint, + method: 'GET', + encodedPath: '/v1/test', + timeoutMs: 10_000, + maxResponseBytes: 1024, + } + const invalidRequests = [ + { ...base, method: 'TRACE' }, + { ...base, encodedPath: 42 }, + { ...base, headers: [] }, + { ...base, queryPairs: [['only-key']] }, + { ...base, queryPairs: [['\ud800', 'value']] }, + { ...base, retry: { kind: 'unknown', maxAttempts: 2 } }, + { ...base, retry: { kind: 'safe', maxAttempts: 2, retryToken: 'forged' } }, + { ...base, responseHeaders: [42] }, + ] + + for (const request of invalidRequests) { + await expect(client.request(request as unknown as OciRequest)).rejects.toMatchObject({ + code: 'invalid_request', }) + } + expect(mocks.secureFetch).not.toHaveBeenCalled() + }) + + it('does not retry unless the operation opts in', async () => { + mocks.secureFetch.mockResolvedValue( + secureResponse({ status: 503, body: '{"message":"secret"}' }) ) - const failure = await sendOciRequest({ - destination, - credentials: { ...credentials, passphrase: longPassphrase }, + const { client, endpoint } = await createPreparedClient() + await expect( + client.request({ + endpoint, + method: 'GET', + encodedPath: '/v1/test', + timeoutMs: 10_000, + maxResponseBytes: 1024, + }) + ).rejects.toMatchObject({ code: 'request_failed', status: 503 }) + expect(mocks.secureFetch).toHaveBeenCalledOnce() + }) + + it('re-signs every retry while preserving exact bytes and retry token', async () => { + mocks.secureFetch + .mockResolvedValueOnce(secureResponse({ status: 503, body: '{"code":"Busy"}' })) + .mockResolvedValueOnce(secureResponse({ status: 200, body: 'ok' })) + const { client, endpoint } = await createPreparedClient() + const body = new Uint8Array([9, 8, 7]) + await client.request({ + endpoint, + method: 'PUT', + encodedPath: '/v1/test', + body, + contentType: 'application/octet-stream', + retry: { kind: 'tokenized', maxAttempts: 2, retryToken: 'operation-token' }, + timeoutMs: 10_000, + maxResponseBytes: 1024, + }) + + const first = mocks.secureFetch.mock.calls[0][1] + const second = mocks.secureFetch.mock.calls[1][1] + expect([...first.body]).toEqual([...second.body]) + expect(first.headers['opc-retry-token']).toBe('operation-token') + expect(second.headers['opc-retry-token']).toBe('operation-token') + expect(first.headers['x-date']).not.toBe(second.headers['x-date']) + expect(first.headers.authorization).not.toBe(second.headers.authorization) + }) + + it('retries only the exact internal IncorrectState 409 classification', async () => { + mocks.secureFetch + .mockResolvedValueOnce(secureResponse({ status: 409, body: '{"code":"IncorrectState"}' })) + .mockResolvedValueOnce(secureResponse({ status: 200 })) + const { client, endpoint } = await createPreparedClient() + await client.request({ + endpoint, method: 'GET', - encodedPath: '/n/', - timeout: 10_000, - maxResponseBytes: 65_536, - }).catch((error: unknown) => error) - expect((failure as Error).message).toContain('[REDACTED]') - expect((failure as Error).message).not.toContain('+'.repeat(1024)) + encodedPath: '/v1/test', + retry: { kind: 'safe', maxAttempts: 2 }, + timeoutMs: 10_000, + maxResponseBytes: 1024, + }) + expect(mocks.secureFetch).toHaveBeenCalledTimes(2) }) - it.each([ - encodeURIComponent('-----BEGIN PRIVATE KEY-----\ntruncated'), - encodeURIComponent(`${destination.origin}/n/truncated`), - '----%2DBEGIN PRIVATE KEY-----', - 'https:%2F%2Fobjectstorage.us-ashburn-1.oraclecloud.com/n/', - ])('fails closed for encoded key or URL prefixes', async (message) => { - secureFetchMock.mockResolvedValueOnce( - secureResponse({ - ok: false, - status: 401, - body: JSON.stringify({ code: 'NotAuthenticated', message }), - }) + it('does not retry another provider 409 classification', async () => { + mocks.secureFetch.mockResolvedValue( + secureResponse({ status: 409, body: '{"code":"Conflict"}' }) ) - const failure = await sendOciRequest({ - destination, - credentials, + const { client, endpoint } = await createPreparedClient() + await expect( + client.request({ + endpoint, + method: 'GET', + encodedPath: '/v1/test', + retry: { kind: 'safe', maxAttempts: 2 }, + timeoutMs: 10_000, + maxResponseBytes: 1024, + }) + ).rejects.toMatchObject({ code: 'request_failed', status: 409 }) + expect(mocks.secureFetch).toHaveBeenCalledOnce() + }) + + it('retries eligible transport failures and rejects unclassified failures', async () => { + const retryable = Object.assign(new Error('socket reset'), { code: 'ECONNRESET' }) + mocks.secureFetch + .mockRejectedValueOnce(retryable) + .mockResolvedValueOnce(secureResponse({ status: 200 })) + const { client, endpoint } = await createPreparedClient() + await client.request({ + endpoint, method: 'GET', - encodedPath: '/n/', - timeout: 10_000, - maxResponseBytes: 65_536, - }).catch((error: unknown) => error) - expect((failure as Error).message).toBe('OCI request failed with status 401') + encodedPath: '/v1/test', + retry: { kind: 'safe', maxAttempts: 2 }, + timeoutMs: 10_000, + maxResponseBytes: 1024, + }) + expect(mocks.secureFetch).toHaveBeenCalledTimes(2) + + mocks.secureFetch.mockReset().mockRejectedValue(new Error('provider diagnostic')) + await expect( + client.request({ + endpoint, + method: 'GET', + encodedPath: '/v1/test', + retry: { kind: 'safe', maxAttempts: 2 }, + timeoutMs: 10_000, + maxResponseBytes: 1024, + }) + ).rejects.toMatchObject({ code: 'request_failed', message: 'OCI request failed' }) + expect(mocks.secureFetch).toHaveBeenCalledOnce() }) - it('redacts generic, spaced, and OCI-specific sensitive JSON fields', async () => { - const echoedSecrets = [ - 'access-value', - 'token-value', - 'secret-value', - 'password-value', - 'signing-string-value', - 'private-key-value', - 'api-key-value', - 'signature-value', - ] - secureFetchMock.mockResolvedValueOnce( + it('discards provider messages and exposes only safe status and request IDs', async () => { + mocks.secureFetch.mockResolvedValueOnce( secureResponse({ - ok: false, status: 401, - body: JSON.stringify({ - code: 'NotAuthenticated', - message: JSON.stringify({ - access_token: echoedSecrets[0], - token: echoedSecrets[1], - secret: echoedSecrets[2], - passphrase: echoedSecrets[3], - signing_string: echoedSecrets[4], - 'private key': echoedSecrets[5], - 'api key': echoedSecrets[6], - signature: echoedSecrets[7], - }), - }), + body: JSON.stringify({ message: PRIVATE_KEY, nested: { authorization: 'secret' } }), + headers: { 'opc-request-id': 'request-401' }, }) ) - const failure = await sendOciRequest({ - destination, - credentials, - method: 'GET', - encodedPath: '/n/', - timeout: 10_000, - maxResponseBytes: 65_536, - }).catch((error: unknown) => error) - expect((failure as Error).message).toContain('[REDACTED]') - for (const secret of echoedSecrets) expect((failure as Error).message).not.toContain(secret) + const { client, endpoint } = await createPreparedClient() + const failure = await client + .request({ + endpoint, + method: 'GET', + encodedPath: '/v1/test', + timeoutMs: 10_000, + maxResponseBytes: 1024, + }) + .catch((error: unknown) => error) + expect(failure).toBeInstanceOf(OciClientError) + expect(failure).toMatchObject({ + code: 'request_failed', + message: 'OCI request failed', + status: 401, + opcRequestId: 'request-401', + }) + expect(JSON.stringify(failure)).not.toContain('BEGIN PRIVATE KEY') + expect(JSON.stringify(failure)).not.toContain('authorization') }) - it('fails closed for authorization signatures with flexible parameter spacing', async () => { - const echoedSignature = 'unknown-provider-signature' - secureFetchMock.mockResolvedValueOnce( + it('returns only selected safe headers and bounded Uint8Array bodies', async () => { + mocks.secureFetch.mockResolvedValueOnce( secureResponse({ - ok: false, - status: 401, - body: JSON.stringify({ - code: 'NotAuthenticated', - message: `provider echoed Signature version = "1", keyId = "unknown", signature = "${echoedSignature}"`, - }), + status: 200, + body: new Uint8Array([1, 2, 3]), + headers: { etag: 'etag-1', 'x-provider-secret': 'hidden' }, }) ) - const failure = await sendOciRequest({ - destination, - credentials, + const { client, endpoint } = await createPreparedClient() + const result = await client.request({ + endpoint, method: 'GET', - encodedPath: '/n/', - timeout: 10_000, - maxResponseBytes: 65_536, - }).catch((error: unknown) => error) - expect((failure as Error).message).toBe('OCI request failed with status 401') - expect((failure as Error).message).not.toContain(echoedSignature) + encodedPath: '/v1/test', + responseHeaders: ['etag'], + timeoutMs: 10_000, + maxResponseBytes: 3, + }) + expect([...result.body]).toEqual([1, 2, 3]) + expect(result.headers.etag).toBe('etag-1') + expect(result.headers).not.toHaveProperty('x-provider-secret') }) - it('bounds non-success response bodies independently of the caller response ceiling', async () => { + it('cancels and classifies a success body beyond the operation limit', async () => { const cancel = vi.fn() - const response = secureResponse({ - ok: false, - status: 502, - opcRequestId: 'request-oversized', - responseBody: new ReadableStream({ + mocks.secureFetch.mockResolvedValueOnce({ + ...secureResponse({ body: new Uint8Array([1, 2, 3, 4]) }), + body: new ReadableStream({ start(controller) { - controller.enqueue(new Uint8Array(65_537)) + controller.enqueue(new Uint8Array([1, 2, 3, 4])) }, cancel, }), }) - secureFetchMock.mockResolvedValueOnce(response) - const failure = await sendOciRequest({ - destination, - credentials, - method: 'GET', - encodedPath: '/n/', - timeout: 10_000, - maxResponseBytes: 1024 * 1024, - }).catch((error: unknown) => error) - expect((failure as Error).message).toBe('OCI request failed with status 502') - expect((failure as OciRequestError).opcRequestId).toBe('request-oversized') - expect(cancel).toHaveBeenCalledOnce() - expect(response.text).not.toHaveBeenCalled() + const { client, endpoint } = await createPreparedClient() + await expect( + client.request({ + endpoint, + method: 'GET', + encodedPath: '/v1/test', + timeoutMs: 10_000, + maxResponseBytes: 3, + }) + ).rejects.toMatchObject({ code: 'response_too_large' }) + expect(cancel).toHaveBeenCalled() }) - it('fails closed for a percent-encoded sensitive JSON key', async () => { - secureFetchMock.mockResolvedValueOnce( + it('rejects fabricated and cross-client authenticated discovery responses', async () => { + const policy = createOciDiscoveredEndpointPolicy({ + serviceId: OCI_SERVICE_ID, + serviceName: 'database', + responsePolicy: STATIC_POLICY, + source: { kind: 'json', path: ['endpoint'] }, + }) + const first = await createPreparedClient() + const second = await createPreparedClient() + mocks.secureFetch.mockResolvedValueOnce( secureResponse({ - ok: false, - status: 401, body: JSON.stringify({ - code: 'NotAuthenticated', - message: JSON.stringify({ 'pass%70hrase': 'provider-echo' }), + endpoint: 'https://resource.database.us-ashburn-1.oraclecloud.com', }), }) ) - const failure = await sendOciRequest({ - destination, - credentials, + const response = await first.client.request({ + endpoint: first.endpoint, method: 'GET', - encodedPath: '/n/', - timeout: 10_000, - maxResponseBytes: 65_536, - }).catch((error: unknown) => error) - expect((failure as Error).message).toBe('OCI request failed with status 401') - }) - - it('fails closed when structured JSON follows a plain-text prefix', async () => { - const message = `provider failed: ${JSON.stringify({ authorization: 'provider-echo' })}` - secureFetchMock.mockResolvedValueOnce( + encodedPath: '/v1/test', + timeoutMs: 10_000, + maxResponseBytes: 1024, + }) + expect((await first.client.prepareDiscoveredEndpoint(policy, response)).origin).toBe( + 'https://resource.database.us-ashburn-1.oraclecloud.com' + ) + await expect(second.client.prepareDiscoveredEndpoint(policy, response)).rejects.toMatchObject({ + code: 'invalid_endpoint', + }) + const otherPolicy = createOciStaticEndpointPolicy({ + serviceId: OCI_SERVICE_ID, + serviceName: 'compute', + }) + const otherEndpoint = await first.client.prepareStaticEndpoint(otherPolicy) + mocks.secureFetch.mockResolvedValueOnce( secureResponse({ - ok: false, - status: 401, - body: JSON.stringify({ code: 'NotAuthenticated', message }), + body: JSON.stringify({ + endpoint: 'https://resource.database.us-ashburn-1.oraclecloud.com', + }), }) ) - const failure = await sendOciRequest({ - destination, - credentials, + const wrongResourceResponse = await first.client.request({ + endpoint: otherEndpoint, method: 'GET', - encodedPath: '/n/', - timeout: 10_000, - maxResponseBytes: 65_536, - }).catch((error: unknown) => error) - expect((failure as Error).message).toBe('OCI request failed with status 401') + encodedPath: '/v1/test', + timeoutMs: 10_000, + maxResponseBytes: 1024, + }) + await expect( + first.client.prepareDiscoveredEndpoint(policy, wrongResourceResponse) + ).rejects.toMatchObject({ code: 'invalid_endpoint' }) + await expect( + first.client.prepareDiscoveredEndpoint(policy, { + status: 200, + headers: {}, + body: new Uint8Array(), + } as OciAuthenticatedResponse) + ).rejects.toMatchObject({ code: 'invalid_endpoint' }) }) - it.each([ - `provider failed: ${JSON.stringify(JSON.stringify({ authorization: 'provider-echo' }))}`, - 'provider failed: \\"authorization\\":\\"provider-echo\\"', - 'signed headers: (request-target) host x-date', - 'signed headers: host x-content-sha256', - ])('fails closed for escaped structured or signing diagnostics', async (message) => { - secureFetchMock.mockResolvedValueOnce( - secureResponse({ - ok: false, - status: 401, - body: JSON.stringify({ code: 'NotAuthenticated', message }), - }) + it('propagates caller abort without leaking a transport failure', async () => { + const controller = new AbortController() + mocks.secureFetch.mockImplementationOnce( + (_url: string, options: { signal: AbortSignal }) => + new Promise((_resolve, reject) => { + options.signal.addEventListener('abort', () => reject(options.signal.reason), { + once: true, + }) + }) ) - const failure = await sendOciRequest({ - destination, - credentials, + const { client, endpoint } = await createPreparedClient() + const pending = client.request({ + endpoint, method: 'GET', - encodedPath: '/n/', - timeout: 10_000, - maxResponseBytes: 65_536, - }).catch((error: unknown) => error) - expect((failure as Error).message).toBe('OCI request failed with status 401') + encodedPath: '/v1/test', + signal: controller.signal, + timeoutMs: 10_000, + maxResponseBytes: 1024, + }) + controller.abort() + await expect(pending).rejects.toMatchObject({ code: 'aborted' }) }) - it.each([ - 'provider echoed \\u0028request-target\\u0029 host x-date', - 'provider echoed \\u0068ttps\\u003a\\u002f\\u002fexample.com', - 'provider echoed \\x28request-target\\x29', - ])('fails closed for Unicode-escaped diagnostics', async (message) => { - secureFetchMock.mockResolvedValueOnce( - secureResponse({ - ok: false, - status: 401, - body: JSON.stringify({ code: 'NotAuthenticated', message }), - }) + it('applies one deadline to in-flight transport work', async () => { + vi.useFakeTimers() + mocks.secureFetch.mockImplementationOnce( + (_url: string, options: { signal: AbortSignal }) => + new Promise((_resolve, reject) => { + options.signal.addEventListener('abort', () => reject(options.signal.reason), { + once: true, + }) + }) ) - const failure = await sendOciRequest({ - destination, - credentials, + const { client, endpoint } = await createPreparedClient() + const pending = client.request({ + endpoint, method: 'GET', - encodedPath: '/n/', - timeout: 10_000, - maxResponseBytes: 65_536, - }).catch((error: unknown) => error) - expect((failure as Error).message).toBe('OCI request failed with status 401') + encodedPath: '/v1/test', + timeoutMs: 100, + maxResponseBytes: 1024, + }) + const assertion = expect(pending).rejects.toMatchObject({ code: 'deadline_exceeded' }) + await vi.advanceTimersByTimeAsync(101) + await assertion }) - it.each([ - '{"authorization":"Signature version=\\"1\\",signature=\\"echoed\\"', - JSON.stringify({ level1: { level2: { level3: { authorization: 'echoed' } } } }), - JSON.stringify({ 'pass%25252570hrase': 'echoed' }), - ])('fails closed for malformed or over-depth structured diagnostics', async (message) => { - secureFetchMock.mockResolvedValueOnce( - secureResponse({ - ok: false, - status: 401, - body: JSON.stringify({ code: 'NotAuthenticated', message }), - }) - ) - const failure = await sendOciRequest({ - destination, - credentials, + it('applies the same deadline while reading the response body', async () => { + vi.useFakeTimers() + const cancel = vi.fn() + mocks.secureFetch.mockResolvedValueOnce({ + ...secureResponse({}), + headers: new Headers(), + body: new ReadableStream({ cancel }), + }) + const { client, endpoint } = await createPreparedClient() + const pending = client.request({ + endpoint, method: 'GET', - encodedPath: '/n/', - timeout: 10_000, - maxResponseBytes: 65_536, - }).catch((error: unknown) => error) - expect((failure as Error).message).toBe('OCI request failed with status 401') + encodedPath: '/v1/test', + timeoutMs: 100, + maxResponseBytes: 1024, + }) + const assertion = expect(pending).rejects.toMatchObject({ code: 'deadline_exceeded' }) + await vi.advanceTimersByTimeAsync(101) + await assertion + expect(cancel).toHaveBeenCalled() }) - it.each([ - '//attacker.example/path', - '/safe//attacker', - '/path?injected=true', - '/path#fragment', - '/path\\replacement', - '/path%ZZ', - '/n/../tenant', - '/n/./tenant', - '/n/%2e/tenant', - '/n/%2E%2E/tenant', - '/n/.%2e/tenant', - ])('rejects unsafe encoded paths: %s', (encodedPath) => { - expect(() => buildOciRequestUrl(destination, encodedPath)).toThrow( - 'single encoded absolute path' + it('propagates caller abort during retry backoff', async () => { + vi.useFakeTimers() + mocks.backoff.mockReturnValue(1000) + mocks.secureFetch.mockResolvedValueOnce( + secureResponse({ status: 503, body: '{"code":"Busy"}' }) ) - }) - - it('rejects invalid transport bounds before signing or sending', async () => { - for (const invalid of [0, -1, Number.NaN, 300_001]) { - await expect( - sendOciRequest({ - destination, - credentials, - method: 'GET', - encodedPath: '/n/', - timeout: invalid, - maxResponseBytes: 65_536, - }) - ).rejects.toThrow('timeout') - } - await expect( - sendOciRequest({ - destination, - credentials, - method: 'GET', - encodedPath: '/n/', - timeout: 10_000, - maxResponseBytes: 100 * 1024 * 1024 + 1, - }) - ).rejects.toThrow('response ceiling') - expect(secureFetchMock).not.toHaveBeenCalled() - }) - - it('propagates a bounded response-ceiling failure without adding request material', async () => { - secureFetchMock.mockRejectedValueOnce(new Error('Response exceeded the configured byte limit')) - const failure = await sendOciRequest({ - destination, - credentials, + const controller = new AbortController() + const { client, endpoint } = await createPreparedClient() + const pending = client.request({ + endpoint, method: 'GET', - encodedPath: '/n/', - timeout: 10_000, - maxResponseBytes: 64, - }).catch((error: unknown) => error) - expect((failure as Error).message).toBe('Response exceeded the configured byte limit') - expect((failure as Error).message).not.toContain('authorization') - expect((failure as Error).message).not.toContain(destination.hostname) + encodedPath: '/v1/test', + retry: { kind: 'safe', maxAttempts: 2 }, + signal: controller.signal, + timeoutMs: 10_000, + maxResponseBytes: 1024, + }) + const assertion = expect(pending).rejects.toMatchObject({ code: 'aborted' }) + await vi.advanceTimersByTimeAsync(1) + controller.abort() + await assertion + expect(mocks.secureFetch).toHaveBeenCalledOnce() }) }) diff --git a/apps/sim/lib/internal/oci/endpoints.test.ts b/apps/sim/lib/internal/oci/endpoints.test.ts index 628ddb1adb9..94a8a1ca4af 100644 --- a/apps/sim/lib/internal/oci/endpoints.test.ts +++ b/apps/sim/lib/internal/oci/endpoints.test.ts @@ -3,25 +3,37 @@ */ import { describe, expect, it } from 'vitest' import { + createOciDiscoveredEndpointPolicy, + createOciStaticEndpointPolicy, getOciRegion, - isObjectStorageOciHostname, OCI_REGION_IDS, - type OciServiceHostnamePredicate, - objectStorageOciDestination, - objectStorageOciHostname, + regionalOciHostname, + resolveDiscoveredOciEndpoint, resolveEffectiveOciRegion, - validateOciDestination, + resolveStaticOciEndpoint, } from '@/lib/internal/oci/endpoints' +import { OCI_SERVICE_ID } from '@/lib/oauth/types' + +const staticPolicy = createOciStaticEndpointPolicy({ + serviceId: OCI_SERVICE_ID, + serviceName: 'identity', +}) +const discoveryPolicy = createOciDiscoveredEndpointPolicy({ + serviceId: OCI_SERVICE_ID, + serviceName: 'database', + responsePolicy: staticPolicy, + source: { kind: 'json', path: ['endpoint'] }, +}) describe('OCI region registry', () => { - it('resolves every snapshotted entry to a consistent realm and domain', () => { + it('resolves every snapshotted region to a known realm domain', () => { expect(OCI_REGION_IDS.length).toBeGreaterThan(80) for (const id of OCI_REGION_IDS) { const region = getOciRegion(id) expect(region.id).toBe(id) expect(region.realm.id).toMatch(/^oc\d+$/) expect(region.realm.domain).toMatch(/^(?:oraclecloud|oraclegovcloud)/) - expect(objectStorageOciHostname(region)).toBe(`objectstorage.${id}.${region.realm.domain}`) + expect(regionalOciHostname('identity', region)).toBe(`identity.${id}.${region.realm.domain}`) } }) @@ -31,104 +43,91 @@ describe('OCI region registry', () => { expect(() => getOciRegion('constructor')).toThrow('not recognized') }) - it('allows only same-realm effective-region overrides', () => { - expect(resolveEffectiveOciRegion('us-ashburn-1').id).toBe('us-ashburn-1') + it('allows only same-realm region overrides', () => { expect(resolveEffectiveOciRegion('us-ashburn-1', 'eu-frankfurt-1').id).toBe('eu-frankfurt-1') expect(() => resolveEffectiveOciRegion('us-ashburn-1', 'us-gov-ashburn-1')).toThrow( 'credential realm' ) - expect(() => resolveEffectiveOciRegion('us-ashburn-1', 'unknown-1')).toThrow('not recognized') }) }) -describe('validateOciDestination', () => { +describe('OCI endpoint policies', () => { const region = getOciRegion('us-ashburn-1') - const origin = 'https://objectstorage.us-ashburn-1.oraclecloud.com' - it.each(['static', 'authenticated-discovery'] as const)( - 'brands a service-owned %s destination', - (provenance) => { - expect(objectStorageOciDestination(region, provenance)).toMatchObject({ - origin, - hostname: 'objectstorage.us-ashburn-1.oraclecloud.com', - service: 'objectstorage', - region, - provenance, - }) - } - ) + it('freezes declarative policies and derives exact static origins', () => { + expect(Object.isFrozen(staticPolicy)).toBe(true) + expect(resolveStaticOciEndpoint(staticPolicy, region)).toMatchObject({ + origin: 'https://identity.us-ashburn-1.oraclecloud.com', + hostname: 'identity.us-ashburn-1.oraclecloud.com', + serviceId: OCI_SERVICE_ID, + serviceName: 'identity', + provenance: 'static', + }) + }) - it.each([ - 'http://objectstorage.us-ashburn-1.oraclecloud.com', - 'https://objectstorage.us-ashburn-1.oraclecloud.com:8443', - 'https://user@objectstorage.us-ashburn-1.oraclecloud.com', - 'https://objectstorage.us-ashburn-1.oraclecloud.com/path', - 'https://objectstorage.us-ashburn-1.oraclecloud.com?query=1', - 'https://objectstorage.us-ashburn-1.oraclecloud.com#fragment', - 'https://127.0.0.1', - ])('rejects a non-origin destination: %s', (candidate) => { - expect(() => - validateOciDestination({ - origin: candidate, - service: 'objectstorage', + it('accepts discovered resource hosts only beneath the declared service, region, and realm', () => { + expect( + resolveDiscoveredOciEndpoint( + discoveryPolicy, region, - provenance: 'static', - isServiceHostname: isObjectStorageOciHostname, - }) - ).toThrow() + 'https://resource.database.us-ashburn-1.oraclecloud.com' + ) + ).toMatchObject({ + serviceName: 'database', + provenance: 'authenticated-discovery', + }) }) it.each([ - 'https://identity.us-ashburn-1.oraclecloud.com', - 'https://objectstorage.eu-frankfurt-1.oraclecloud.com', - 'https://objectstorage.us-ashburn-1.oraclegovcloud.com', - 'https://objectstorage.us-ashburn-1.example.com', - ])('rejects a hostname outside the service and effective region: %s', (candidate) => { - expect(() => - validateOciDestination({ - origin: candidate, - service: 'objectstorage', - region, - provenance: 'authenticated-discovery', - isServiceHostname: isObjectStorageOciHostname, - }) - ).toThrow('not owned') + 'http://resource.database.us-ashburn-1.oraclecloud.com', + 'https://resource.database.us-ashburn-1.oraclecloud.com:8443', + 'https://user@resource.database.us-ashburn-1.oraclecloud.com', + 'https://resource.database.us-ashburn-1.oraclecloud.com/path', + 'https://127.0.0.1', + 'https://database.us-ashburn-1.oraclecloud.com', + 'https://resource.database.eu-frankfurt-1.oraclecloud.com', + 'https://resource.database.us-ashburn-1.oraclegovcloud.com', + 'https://resource.database.us-ashburn-1.example.com', + ])('rejects an origin outside the discovery policy: %s', (origin) => { + expect(() => resolveDiscoveredOciEndpoint(discoveryPolicy, region, origin)).toThrow() }) - it('binds the hostname predicate to its service constant', () => { - expect(() => - validateOciDestination({ - origin, - service: 'identity', - region, - provenance: 'static', - isServiceHostname: isObjectStorageOciHostname, - }) - ).toThrow('not owned') + it('can explicitly permit the regional service host for authenticated discovery', () => { + const policy = createOciDiscoveredEndpointPolicy({ + serviceId: OCI_SERVICE_ID, + serviceName: 'database', + responsePolicy: staticPolicy, + source: { kind: 'header', name: 'Endpoint' }, + allowRegionalHost: true, + }) + expect( + resolveDiscoveredOciEndpoint(policy, region, 'https://database.us-ashburn-1.oraclecloud.com') + .origin + ).toBe('https://database.us-ashburn-1.oraclecloud.com') + expect(policy.source).toEqual({ kind: 'header', name: 'endpoint' }) + expect(Object.isFrozen(policy.source)).toBe(true) }) - it('rejects a bracketed IPv6 literal before applying the service predicate', () => { - const acceptsEveryHostname = (() => true) as OciServiceHostnamePredicate + it('rejects malformed policy declarations and forged region mappings', () => { expect(() => - validateOciDestination({ - origin: 'https://[2606:4700::1111]', - service: 'objectstorage', - region, - provenance: 'static', - isServiceHostname: acceptsEveryHostname, - }) - ).toThrow('exact HTTPS origin') - }) - - it('rejects a forged region-to-realm association', () => { + createOciStaticEndpointPolicy({ serviceId: OCI_SERVICE_ID, serviceName: 'bad.name' }) + ).toThrow('service name') expect(() => - validateOciDestination({ - origin, - service: 'objectstorage', - region: { id: region.id, realm: { id: 'oc2', domain: 'oraclegovcloud.com' } }, - provenance: 'static', - isServiceHostname: isObjectStorageOciHostname, + resolveStaticOciEndpoint(staticPolicy, { + id: region.id, + realm: { id: 'oc2', domain: 'oraclegovcloud.com' }, }) ).toThrow('known registry') + expect(() => + createOciDiscoveredEndpointPolicy({ + serviceId: OCI_SERVICE_ID, + serviceName: 'database', + responsePolicy: createOciStaticEndpointPolicy({ + serviceId: 'slack', + serviceName: 'identity', + }), + source: { kind: 'json', path: ['endpoint'] }, + }) + ).toThrow('same owning service') }) }) diff --git a/apps/sim/lib/oauth/credential-service.test.ts b/apps/sim/lib/oauth/credential-service.test.ts index 8f14f28e6de..6cb4b4f3434 100644 --- a/apps/sim/lib/oauth/credential-service.test.ts +++ b/apps/sim/lib/oauth/credential-service.test.ts @@ -69,7 +69,10 @@ vi.mock('@/lib/oauth/terminal-errors', () => ({ markCredentialDead: vi.fn(), })) -import { resolveCredentialTokenBundle } from '@/lib/oauth/credential-service' +import { + resolveCredentialTokenBundle, + resolveServiceAccountToken, +} from '@/lib/oauth/credential-service' const RAW_CREDENTIAL_ID = 'credential-raw-secret-id' const RAW_ACCOUNT_ID = 'account-raw-secret-id' @@ -278,3 +281,16 @@ describe('resolveCredentialTokenBundle selector privacy', () => { ) }) }) + +describe('OCI service-account resolver', () => { + it('returns only the authoritative resolved credential ID for hidden in-process handoff', async () => { + await expect( + resolveServiceAccountToken( + 'credential-authoritative', + 'oci-api-key-service-account', + [], + undefined + ) + ).resolves.toEqual({ accessToken: 'credential-authoritative' }) + }) +}) diff --git a/apps/sim/lib/oauth/token-resolution.test.ts b/apps/sim/lib/oauth/token-resolution.test.ts index 799c21748c8..aaa7ae81d23 100644 --- a/apps/sim/lib/oauth/token-resolution.test.ts +++ b/apps/sim/lib/oauth/token-resolution.test.ts @@ -8,6 +8,7 @@ const { mockCaptureServerEvent, mockExecuteManagedToken, mockGetCredential, + mockGetServiceConfigByServiceId, mockGetToolMetadata, mockRecordAudit, mockRefreshTokenIfNeeded, @@ -18,6 +19,7 @@ const { mockCaptureServerEvent: vi.fn(), mockExecuteManagedToken: vi.fn(), mockGetCredential: vi.fn(), + mockGetServiceConfigByServiceId: vi.fn(), mockGetToolMetadata: vi.fn(), mockRecordAudit: vi.fn(), mockRefreshTokenIfNeeded: vi.fn(), @@ -79,6 +81,7 @@ vi.mock('@/tools/metadata', () => ({ vi.mock('@/lib/oauth/utils', () => ({ getCanonicalScopesForProvider: vi.fn().mockReturnValue([]), + getServiceConfigByServiceId: mockGetServiceConfigByServiceId, })) import { OrchestrationError } from '@/lib/core/orchestration/types' @@ -317,7 +320,20 @@ describe('resolveCredentialToken', () => { }) it('surfaces the classified service-account failure code', async () => { - mockAuthorizeCredentialUseForAuth.mockResolvedValue({ ok: true, requesterUserId: 'user-1' }) + mockAuthorizeCredentialUseForAuth.mockResolvedValue({ + ok: true, + requesterUserId: 'user-1', + workspaceId: 'ws-1', + resolvedCredentialId: 'sa-authoritative', + }) + mockResolveOAuthAccountId.mockResolvedValue({ + credentialType: 'service_account', + credentialId: 'sa-authoritative', + providerId: 'atlassian', + workspaceId: 'ws-1', + accountId: '', + usedCredentialTable: true, + }) mockResolveServiceAccountToken.mockRejectedValue( new TokenServiceAccountValidationError('invalid_credentials', 401) ) @@ -341,6 +357,12 @@ describe('resolveCredentialToken', () => { code: 'invalid_credentials', error: 'Credential rejected by the provider — reconnect the credential', }) + expect(mockResolveServiceAccountToken).toHaveBeenCalledWith( + 'sa-authoritative', + 'atlassian', + [], + undefined + ) }) it('rejects a malformed impersonation subject before touching the credential', async () => { @@ -379,6 +401,7 @@ describe('resolveCredentialAccessToken', () => { beforeEach(() => { vi.clearAllMocks() mockResolveOAuthAccountId.mockResolvedValue(null) + mockGetServiceConfigByServiceId.mockReturnValue(null) authenticate.mockResolvedValue(INTERNAL_AUTH) resolveManagedPrincipal.mockResolvedValue(EXECUTOR_PRINCIPAL) mockGetToolMetadata.mockReturnValue({ @@ -444,6 +467,129 @@ describe('resolveCredentialAccessToken', () => { }) }) + it('hands an authorized OCI credential to the resolver by authoritative ID only', async () => { + const supplied = { + credentialType: 'service_account', + credentialId: 'caller-controlled-alias', + providerId: 'google-service-account', + workspaceId: 'ws-1', + accountId: '', + usedCredentialTable: true, + } as const + const authoritative = { + ...supplied, + credentialId: 'credential-authoritative', + providerId: 'oci-api-key-service-account', + } as const + mockResolveOAuthAccountId.mockResolvedValueOnce(supplied).mockResolvedValueOnce(authoritative) + mockGetToolMetadata.mockReturnValue({ + oauth: { + required: true, + provider: 'oci', + credentialKind: 'service-account', + }, + }) + mockGetServiceConfigByServiceId.mockReturnValue({ + serviceAccountProviderId: 'oci-api-key-service-account', + }) + mockAuthorizeCredentialUseForAuth.mockResolvedValue({ + ok: true, + requesterUserId: 'user-1', + workspaceId: 'ws-1', + resolvedCredentialId: 'credential-authoritative', + }) + mockResolveServiceAccountToken.mockResolvedValue({ accessToken: 'credential-authoritative' }) + + await expect( + resolveCredentialAccessToken({ + requestId: 'req-oci', + credentialId: 'caller-controlled-alias', + toolId: 'future_oci_tool', + authenticate, + }) + ).resolves.toEqual({ + ok: true, + token: expect.objectContaining({ accessToken: 'credential-authoritative' }), + }) + expect(mockResolveServiceAccountToken).toHaveBeenCalledWith( + 'credential-authoritative', + 'oci-api-key-service-account', + [], + undefined + ) + }) + + it('rejects OCI credentials when trusted tool metadata is not provider-bound', async () => { + mockResolveOAuthAccountId.mockResolvedValue({ + credentialType: 'service_account', + credentialId: 'credential-authoritative', + providerId: 'oci-api-key-service-account', + workspaceId: 'ws-1', + accountId: '', + usedCredentialTable: true, + }) + mockGetToolMetadata.mockReturnValue({ + oauth: { required: true, provider: 'oci', credentialKind: 'service-account' }, + }) + mockGetServiceConfigByServiceId.mockReturnValue({ + serviceAccountProviderId: 'different-provider', + }) + + await expect( + resolveCredentialAccessToken({ + requestId: 'req-oci', + credentialId: 'credential-authoritative', + toolId: 'future_oci_tool', + authenticate, + }) + ).resolves.toMatchObject({ + ok: false, + status: 500, + code: 'OCI_CREDENTIAL_TOOL_UNSUPPORTED', + }) + expect(authenticate).not.toHaveBeenCalled() + expect(mockResolveServiceAccountToken).not.toHaveBeenCalled() + }) + + it('cannot use a non-OCI alias to bypass trusted OCI tool metadata checks', async () => { + const supplied = { + credentialType: 'service_account', + credentialId: 'caller-controlled-alias', + providerId: 'google-service-account', + workspaceId: 'ws-1', + accountId: '', + usedCredentialTable: true, + } as const + const authoritative = { + ...supplied, + credentialId: 'credential-authoritative', + providerId: 'oci-api-key-service-account', + } as const + mockResolveOAuthAccountId.mockResolvedValueOnce(supplied).mockResolvedValueOnce(authoritative) + mockGetToolMetadata.mockReturnValue({ + oauth: { required: true, provider: 'slack', credentialKind: 'service-account' }, + }) + mockGetServiceConfigByServiceId.mockReturnValue({ + serviceAccountProviderId: 'slack-custom-bot', + }) + mockAuthorizeCredentialUseForAuth.mockResolvedValue({ + ok: true, + requesterUserId: 'user-1', + workspaceId: 'ws-1', + resolvedCredentialId: 'credential-authoritative', + }) + + await expect( + resolveCredentialAccessToken({ + requestId: 'req-oci', + credentialId: 'caller-controlled-alias', + toolId: 'non_oci_tool', + authenticate, + }) + ).resolves.toEqual({ ok: false, status: 403, error: 'Unauthorized' }) + expect(mockResolveServiceAccountToken).not.toHaveBeenCalled() + }) + it('rejects a managed credential when no delegation resolver is wired', async () => { mockResolveOAuthAccountId.mockResolvedValue(MANAGED_RESOLVED) diff --git a/apps/sim/lib/oauth/utils.test.ts b/apps/sim/lib/oauth/utils.test.ts index 3c357d6bb1b..c8293468db9 100644 --- a/apps/sim/lib/oauth/utils.test.ts +++ b/apps/sim/lib/oauth/utils.test.ts @@ -121,6 +121,10 @@ describe('getAllOAuthServices', () => { serviceId: 'gmail', authType: 'oauth', }) + expect(getServiceConfigByServiceId('oci')).toMatchObject({ + authType: 'service_account', + serviceAccountProviderId: 'oci-api-key-service-account', + }) }) }) diff --git a/apps/sim/lib/selectors/server/credentials.test.ts b/apps/sim/lib/selectors/server/credentials.test.ts index 17138d2079a..28d53da212d 100644 --- a/apps/sim/lib/selectors/server/credentials.test.ts +++ b/apps/sim/lib/selectors/server/credentials.test.ts @@ -3,7 +3,7 @@ */ import { credential } from '@sim/db/schema' -import { queueTableRows, resetDbChainMock } from '@sim/testing' +import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ @@ -89,6 +89,9 @@ describe('authorizeSelectorCredential', () => { workspaceId: 'workspace-1', }) ) + const providerPredicate = JSON.stringify(dbChainMockFns.where.mock.calls.at(-1)?.[0]) + expect(providerPredicate).toContain('account-1') + expect(providerPredicate).not.toContain('credential-1') }) it('promotes a hidden fixed token to an authentication secret at every length', async () => { diff --git a/apps/sim/tools/index.test.ts b/apps/sim/tools/index.test.ts index 1cb84748caa..4636bedf0a5 100644 --- a/apps/sim/tools/index.test.ts +++ b/apps/sim/tools/index.test.ts @@ -3176,6 +3176,51 @@ describe('Internal Route Trust', () => { } }) + it('unconditionally overwrites a caller-supplied hidden credential value', async () => { + const toolId = 'test_hidden_credential_authority' + const mockTool = { + id: toolId, + name: 'Hidden Credential Authority Test', + description: 'Verifies authoritative hidden credential injection', + version: '1.0.0', + oauth: { + required: true, + provider: 'google', + credentialKind: 'oauth' as const, + }, + params: { + accessToken: { type: 'string', required: true, visibility: 'hidden' }, + }, + request: { + url: () => 'https://www.googleapis.com/test', + method: 'GET' as const, + headers: (params: Record) => ({ + Authorization: `Bearer ${params.accessToken}`, + }), + }, + transformResponse: vi.fn().mockResolvedValue({ success: true, output: {} }), + } + ;(tools as Record)[toolId] = mockTool + mockResolveExecutorCredentialToken.mockResolvedValue({ + accessToken: 'authorized-value', + credentialType: 'oauth', + }) + + try { + const result = await executeTool(toolId, { + credential: 'selected-credential', + accessToken: 'caller-forged-value', + }) + + expect(result.success).toBe(true) + const requestOptions = mockSecureFetchWithPinnedIP.mock.calls.at(-1)?.[2] + expect(requestOptions?.headers.authorization).toBe('Bearer authorized-value') + expect(JSON.stringify(requestOptions)).not.toContain('caller-forged-value') + } finally { + Reflect.deleteProperty(tools, toolId) + } + }) + it('transports only active provenance selected for an internal model input', async () => { const registry = new ResolvedSecretTraceRegistry([ { From 84fe75ad271683718b65ef1320561b1c8c0f039c Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Thu, 3 Sep 2026 19:08:47 -0700 Subject: [PATCH 10/29] fix(oci): mark signing fixture as synthetic --- apps/sim/lib/internal/oci/client.server.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/internal/oci/client.server.test.ts b/apps/sim/lib/internal/oci/client.server.test.ts index 70579b62066..5b11384fac4 100644 --- a/apps/sim/lib/internal/oci/client.server.test.ts +++ b/apps/sim/lib/internal/oci/client.server.test.ts @@ -79,7 +79,9 @@ import { OCI_SERVICE_ID } from '@/lib/oauth/types' // OpenSSL 3 against Oracle's Request Signatures specification (retrieved 2026-09-03): // https://docs.oracle.com/en-us/iaas/Content/API/Concepts/signingrequests.htm // The canonical header order is cross-checked against oci-common 2.140.0. -const PRIVATE_KEY = `-----BEGIN PRIVATE KEY----- +// Keep the synthetic fixture's PEM delimiters split so secret scanners do not +// mistake checked-in conformance material for a deployable credential. +const PRIVATE_KEY = `${['-----BEGIN', 'PRIVATE KEY-----'].join(' ')} MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDGu21M7TuK4Jr6 s8luoTzVRltBhYM078Z0JNpg3/uwqLIYtmNFDLg9AJ4NY9piBfZoE4b9EhrVzwkW +wIWdSflJPfnlWFD7nLBk+n69dyU1wwUuEw0PYZOliFvCmlegg9qE+vZK13o5e1m @@ -106,7 +108,7 @@ w+bvLZkxAFODuFuJ+SKL9qx8u42sa181dKtEaUJVAoGBALuFS1q/ihZw8M5AoofY llBvP7/pHwT8XR2gWl5sZFOt6kvrMQqcI3u/9BkVR9au1I2K7xJOQmt9KEL4HkgP 6cqql61lZNv8GgYlJPu8ipN0IUxf1V7K+9xw0t1am57WATCW+bqkfyvYoBXhLwx6 7z8JESybW/3kkmWIOy5WHvzv ------END PRIVATE KEY----- +${['-----END', 'PRIVATE KEY-----'].join(' ')} ` const SECRET = JSON.stringify({ From 71d833070c0b9f508a9b001b06f1dd10b30a8d2c Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Thu, 3 Sep 2026 19:16:27 -0700 Subject: [PATCH 11/29] fix(oci): align authorization regression coverage --- .../app/api/auth/oauth/token/route.test.ts | 32 ++++++++++++++----- .../integrations/credential-display.test.ts | 3 ++ 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/apps/sim/app/api/auth/oauth/token/route.test.ts b/apps/sim/app/api/auth/oauth/token/route.test.ts index 9c10df2e236..1903b59e0d8 100644 --- a/apps/sim/app/api/auth/oauth/token/route.test.ts +++ b/apps/sim/app/api/auth/oauth/token/route.test.ts @@ -328,19 +328,23 @@ describe('OAuth Token API Routes', () => { describe('service account path', () => { it('threads the NetSuite SuiteTalk instance URL into the token response', async () => { const instanceUrl = 'https://1234567.suitetalk.api.netsuite.com' - authOAuthUtilsMockFns.mockResolveOAuthAccountId.mockResolvedValueOnce({ + const resolvedCredential = { accountId: '', credentialId: 'netsuite-credential-id', credentialType: 'service_account', providerId: 'netsuite-service-account', workspaceId: 'workspace-id', usedCredentialTable: true, - }) + } as const + authOAuthUtilsMockFns.mockResolveOAuthAccountId + .mockResolvedValueOnce(resolvedCredential) + .mockResolvedValueOnce(resolvedCredential) mockAuthorizeCredentialUse.mockResolvedValueOnce({ ok: true, authType: 'session', requesterUserId: 'test-user-id', workspaceId: 'workspace-id', + resolvedCredentialId: 'netsuite-credential-id', }) mockResolveServiceAccountToken.mockResolvedValueOnce({ accessToken: 'netsuite-token', @@ -357,19 +361,23 @@ describe('OAuth Token API Routes', () => { }) it('should thread authStyle from the resolver into the response', async () => { - authOAuthUtilsMockFns.mockResolveOAuthAccountId.mockResolvedValueOnce({ + const resolvedCredential = { accountId: '', credentialId: 'sa-credential-id', credentialType: 'service_account', providerId: 'pipedrive-service-account', workspaceId: 'workspace-id', usedCredentialTable: true, - }) + } as const + authOAuthUtilsMockFns.mockResolveOAuthAccountId + .mockResolvedValueOnce(resolvedCredential) + .mockResolvedValueOnce(resolvedCredential) mockAuthorizeCredentialUse.mockResolvedValueOnce({ ok: true, authType: 'session', requesterUserId: 'test-user-id', workspaceId: 'workspace-id', + resolvedCredentialId: 'sa-credential-id', }) mockResolveServiceAccountToken.mockResolvedValueOnce({ accessToken: 'pasted-api-token', @@ -387,19 +395,23 @@ describe('OAuth Token API Routes', () => { }) it('should omit authStyle for Bearer token-paste providers', async () => { - authOAuthUtilsMockFns.mockResolveOAuthAccountId.mockResolvedValueOnce({ + const resolvedCredential = { accountId: '', credentialId: 'sa-credential-id', credentialType: 'service_account', providerId: 'hubspot-service-account', workspaceId: 'workspace-id', usedCredentialTable: true, - }) + } as const + authOAuthUtilsMockFns.mockResolveOAuthAccountId + .mockResolvedValueOnce(resolvedCredential) + .mockResolvedValueOnce(resolvedCredential) mockAuthorizeCredentialUse.mockResolvedValueOnce({ ok: true, authType: 'session', requesterUserId: 'test-user-id', workspaceId: 'workspace-id', + resolvedCredentialId: 'sa-credential-id', }) mockResolveServiceAccountToken.mockResolvedValueOnce({ accessToken: 'pat-token', @@ -422,19 +434,23 @@ describe('OAuth Token API Routes', () => { ] as const)( 'surfaces the %s error code with status %i when the mint fails', async (code, status) => { - authOAuthUtilsMockFns.mockResolveOAuthAccountId.mockResolvedValueOnce({ + const resolvedCredential = { accountId: '', credentialId: 'sa-credential-id', credentialType: 'service_account', providerId: 'salesforce-service-account', workspaceId: 'workspace-id', usedCredentialTable: true, - }) + } as const + authOAuthUtilsMockFns.mockResolveOAuthAccountId + .mockResolvedValueOnce(resolvedCredential) + .mockResolvedValueOnce(resolvedCredential) mockAuthorizeCredentialUse.mockResolvedValueOnce({ ok: true, authType: 'session', requesterUserId: 'test-user-id', workspaceId: 'workspace-id', + resolvedCredentialId: 'sa-credential-id', }) mockResolveServiceAccountToken.mockRejectedValueOnce( new TokenServiceAccountValidationError(code, status, { step: 'mint' }) diff --git a/apps/sim/lib/integrations/credential-display.test.ts b/apps/sim/lib/integrations/credential-display.test.ts index f73da60137f..bc3b15350c9 100644 --- a/apps/sim/lib/integrations/credential-display.test.ts +++ b/apps/sim/lib/integrations/credential-display.test.ts @@ -65,6 +65,9 @@ const EXPECTED_COVERAGE: Record = { 'linear-service-account': ['linear'], 'monday-service-account': ['monday'], 'notion-service-account': ['notion'], + // OCI owns reusable credential setup but intentionally exposes no product + // integration until a native OCI product supplies visible catalog metadata. + 'oci-api-key-service-account': [], // NetSuite remains an API-key catalog integration, like Snowflake, while its // block uses the shared reusable-credential selector. 'netsuite-service-account': [], From 05c4a4eca34b38b8d441888b2a7f834e8b4d158e Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Fri, 4 Sep 2026 09:35:18 -0700 Subject: [PATCH 12/29] refactor(oci): isolate credential handoff --- .../app/api/auth/oauth/token/route.test.ts | 32 +++------- apps/sim/lib/oauth/token-resolution.test.ts | 60 +------------------ apps/sim/lib/oauth/token-resolution.ts | 46 +++++++------- .../lib/selectors/server/credentials.test.ts | 5 +- apps/sim/lib/selectors/server/credentials.ts | 4 +- apps/sim/tools/index.test.ts | 45 -------------- 6 files changed, 37 insertions(+), 155 deletions(-) diff --git a/apps/sim/app/api/auth/oauth/token/route.test.ts b/apps/sim/app/api/auth/oauth/token/route.test.ts index 1903b59e0d8..9c10df2e236 100644 --- a/apps/sim/app/api/auth/oauth/token/route.test.ts +++ b/apps/sim/app/api/auth/oauth/token/route.test.ts @@ -328,23 +328,19 @@ describe('OAuth Token API Routes', () => { describe('service account path', () => { it('threads the NetSuite SuiteTalk instance URL into the token response', async () => { const instanceUrl = 'https://1234567.suitetalk.api.netsuite.com' - const resolvedCredential = { + authOAuthUtilsMockFns.mockResolveOAuthAccountId.mockResolvedValueOnce({ accountId: '', credentialId: 'netsuite-credential-id', credentialType: 'service_account', providerId: 'netsuite-service-account', workspaceId: 'workspace-id', usedCredentialTable: true, - } as const - authOAuthUtilsMockFns.mockResolveOAuthAccountId - .mockResolvedValueOnce(resolvedCredential) - .mockResolvedValueOnce(resolvedCredential) + }) mockAuthorizeCredentialUse.mockResolvedValueOnce({ ok: true, authType: 'session', requesterUserId: 'test-user-id', workspaceId: 'workspace-id', - resolvedCredentialId: 'netsuite-credential-id', }) mockResolveServiceAccountToken.mockResolvedValueOnce({ accessToken: 'netsuite-token', @@ -361,23 +357,19 @@ describe('OAuth Token API Routes', () => { }) it('should thread authStyle from the resolver into the response', async () => { - const resolvedCredential = { + authOAuthUtilsMockFns.mockResolveOAuthAccountId.mockResolvedValueOnce({ accountId: '', credentialId: 'sa-credential-id', credentialType: 'service_account', providerId: 'pipedrive-service-account', workspaceId: 'workspace-id', usedCredentialTable: true, - } as const - authOAuthUtilsMockFns.mockResolveOAuthAccountId - .mockResolvedValueOnce(resolvedCredential) - .mockResolvedValueOnce(resolvedCredential) + }) mockAuthorizeCredentialUse.mockResolvedValueOnce({ ok: true, authType: 'session', requesterUserId: 'test-user-id', workspaceId: 'workspace-id', - resolvedCredentialId: 'sa-credential-id', }) mockResolveServiceAccountToken.mockResolvedValueOnce({ accessToken: 'pasted-api-token', @@ -395,23 +387,19 @@ describe('OAuth Token API Routes', () => { }) it('should omit authStyle for Bearer token-paste providers', async () => { - const resolvedCredential = { + authOAuthUtilsMockFns.mockResolveOAuthAccountId.mockResolvedValueOnce({ accountId: '', credentialId: 'sa-credential-id', credentialType: 'service_account', providerId: 'hubspot-service-account', workspaceId: 'workspace-id', usedCredentialTable: true, - } as const - authOAuthUtilsMockFns.mockResolveOAuthAccountId - .mockResolvedValueOnce(resolvedCredential) - .mockResolvedValueOnce(resolvedCredential) + }) mockAuthorizeCredentialUse.mockResolvedValueOnce({ ok: true, authType: 'session', requesterUserId: 'test-user-id', workspaceId: 'workspace-id', - resolvedCredentialId: 'sa-credential-id', }) mockResolveServiceAccountToken.mockResolvedValueOnce({ accessToken: 'pat-token', @@ -434,23 +422,19 @@ describe('OAuth Token API Routes', () => { ] as const)( 'surfaces the %s error code with status %i when the mint fails', async (code, status) => { - const resolvedCredential = { + authOAuthUtilsMockFns.mockResolveOAuthAccountId.mockResolvedValueOnce({ accountId: '', credentialId: 'sa-credential-id', credentialType: 'service_account', providerId: 'salesforce-service-account', workspaceId: 'workspace-id', usedCredentialTable: true, - } as const - authOAuthUtilsMockFns.mockResolveOAuthAccountId - .mockResolvedValueOnce(resolvedCredential) - .mockResolvedValueOnce(resolvedCredential) + }) mockAuthorizeCredentialUse.mockResolvedValueOnce({ ok: true, authType: 'session', requesterUserId: 'test-user-id', workspaceId: 'workspace-id', - resolvedCredentialId: 'sa-credential-id', }) mockResolveServiceAccountToken.mockRejectedValueOnce( new TokenServiceAccountValidationError(code, status, { step: 'mint' }) diff --git a/apps/sim/lib/oauth/token-resolution.test.ts b/apps/sim/lib/oauth/token-resolution.test.ts index aaa7ae81d23..e1cb1ff81b0 100644 --- a/apps/sim/lib/oauth/token-resolution.test.ts +++ b/apps/sim/lib/oauth/token-resolution.test.ts @@ -320,20 +320,7 @@ describe('resolveCredentialToken', () => { }) it('surfaces the classified service-account failure code', async () => { - mockAuthorizeCredentialUseForAuth.mockResolvedValue({ - ok: true, - requesterUserId: 'user-1', - workspaceId: 'ws-1', - resolvedCredentialId: 'sa-authoritative', - }) - mockResolveOAuthAccountId.mockResolvedValue({ - credentialType: 'service_account', - credentialId: 'sa-authoritative', - providerId: 'atlassian', - workspaceId: 'ws-1', - accountId: '', - usedCredentialTable: true, - }) + mockAuthorizeCredentialUseForAuth.mockResolvedValue({ ok: true, requesterUserId: 'user-1' }) mockResolveServiceAccountToken.mockRejectedValue( new TokenServiceAccountValidationError('invalid_credentials', 401) ) @@ -357,12 +344,6 @@ describe('resolveCredentialToken', () => { code: 'invalid_credentials', error: 'Credential rejected by the provider — reconnect the credential', }) - expect(mockResolveServiceAccountToken).toHaveBeenCalledWith( - 'sa-authoritative', - 'atlassian', - [], - undefined - ) }) it('rejects a malformed impersonation subject before touching the credential', async () => { @@ -551,45 +532,6 @@ describe('resolveCredentialAccessToken', () => { expect(mockResolveServiceAccountToken).not.toHaveBeenCalled() }) - it('cannot use a non-OCI alias to bypass trusted OCI tool metadata checks', async () => { - const supplied = { - credentialType: 'service_account', - credentialId: 'caller-controlled-alias', - providerId: 'google-service-account', - workspaceId: 'ws-1', - accountId: '', - usedCredentialTable: true, - } as const - const authoritative = { - ...supplied, - credentialId: 'credential-authoritative', - providerId: 'oci-api-key-service-account', - } as const - mockResolveOAuthAccountId.mockResolvedValueOnce(supplied).mockResolvedValueOnce(authoritative) - mockGetToolMetadata.mockReturnValue({ - oauth: { required: true, provider: 'slack', credentialKind: 'service-account' }, - }) - mockGetServiceConfigByServiceId.mockReturnValue({ - serviceAccountProviderId: 'slack-custom-bot', - }) - mockAuthorizeCredentialUseForAuth.mockResolvedValue({ - ok: true, - requesterUserId: 'user-1', - workspaceId: 'ws-1', - resolvedCredentialId: 'credential-authoritative', - }) - - await expect( - resolveCredentialAccessToken({ - requestId: 'req-oci', - credentialId: 'caller-controlled-alias', - toolId: 'non_oci_tool', - authenticate, - }) - ).resolves.toEqual({ ok: false, status: 403, error: 'Unauthorized' }) - expect(mockResolveServiceAccountToken).not.toHaveBeenCalled() - }) - it('rejects a managed credential when no delegation resolver is wired', async () => { mockResolveOAuthAccountId.mockResolvedValue(MANAGED_RESOLVED) diff --git a/apps/sim/lib/oauth/token-resolution.ts b/apps/sim/lib/oauth/token-resolution.ts index 755a48ecd43..89b444bcb86 100644 --- a/apps/sim/lib/oauth/token-resolution.ts +++ b/apps/sim/lib/oauth/token-resolution.ts @@ -62,8 +62,8 @@ export interface ResolveCredentialTokenInput { auditRequest?: CredentialAuditRequest /** Credential lookup already performed by {@link resolveCredentialAccessToken}'s dispatch. */ resolvedCredential: ResolvedCredential | null - /** Trusted provider binding derived from registered tool metadata. */ - expectedServiceAccountProviderId?: string + /** Trusted OCI provider binding derived from registered tool metadata. */ + expectedServiceAccountProviderId?: typeof OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID } export type ResolveCredentialTokenResult = @@ -265,27 +265,31 @@ export async function resolveCredentialToken( return { ok: false, status: 403, error: authz.error || 'Unauthorized' } } - const authoritativeId = authz.resolvedCredentialId - if (!authoritativeId) return { ok: false, status: 403, error: 'Unauthorized' } - const authoritative = await resolveOAuthAccountId(authoritativeId) - if ( - authoritative?.credentialType !== 'service_account' || - authoritative.credentialId !== authoritativeId || - (authoritative.providerId === OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID && - input.expectedServiceAccountProviderId !== OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID) || - (input.expectedServiceAccountProviderId !== undefined && - authoritative.providerId !== input.expectedServiceAccountProviderId) - ) { - return { ok: false, status: 403, error: 'Unauthorized' } - } - + let serviceAccountCredentialId = resolved.credentialId + let serviceAccountProviderId = resolved.providerId const saActorId = authz.requesterUserId - const saWorkspaceId = authz.workspaceId ?? null + let saWorkspaceId = resolved.workspaceId ?? authz.workspaceId ?? null + + if (input.expectedServiceAccountProviderId === OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID) { + const authoritativeId = authz.resolvedCredentialId + if (!authoritativeId) return { ok: false, status: 403, error: 'Unauthorized' } + const authoritative = await resolveOAuthAccountId(authoritativeId) + if ( + authoritative?.credentialType !== 'service_account' || + authoritative.credentialId !== authoritativeId || + authoritative.providerId !== OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID + ) { + return { ok: false, status: 403, error: 'Unauthorized' } + } + serviceAccountCredentialId = authoritativeId + serviceAccountProviderId = authoritative.providerId + saWorkspaceId = authz.workspaceId ?? null + } try { const result = await resolveServiceAccountToken( - authoritativeId, - authoritative.providerId, + serviceAccountCredentialId, + serviceAccountProviderId, scopes ?? [], impersonateEmail ) @@ -294,8 +298,8 @@ export async function resolveCredentialToken( recordCredentialAccess({ actorId: saActorId, workspaceId: saWorkspaceId, - resourceId: authoritativeId, - providerId: authoritative.providerId, + resourceId: serviceAccountCredentialId, + providerId: serviceAccountProviderId, credentialType: 'service_account', auditRequest, }) diff --git a/apps/sim/lib/selectors/server/credentials.test.ts b/apps/sim/lib/selectors/server/credentials.test.ts index 28d53da212d..17138d2079a 100644 --- a/apps/sim/lib/selectors/server/credentials.test.ts +++ b/apps/sim/lib/selectors/server/credentials.test.ts @@ -3,7 +3,7 @@ */ import { credential } from '@sim/db/schema' -import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { queueTableRows, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ @@ -89,9 +89,6 @@ describe('authorizeSelectorCredential', () => { workspaceId: 'workspace-1', }) ) - const providerPredicate = JSON.stringify(dbChainMockFns.where.mock.calls.at(-1)?.[0]) - expect(providerPredicate).toContain('account-1') - expect(providerPredicate).not.toContain('credential-1') }) it('promotes a hidden fixed token to an authentication secret at every length', async () => { diff --git a/apps/sim/lib/selectors/server/credentials.ts b/apps/sim/lib/selectors/server/credentials.ts index 8dfad516583..2bc68c62275 100644 --- a/apps/sim/lib/selectors/server/credentials.ts +++ b/apps/sim/lib/selectors/server/credentials.ts @@ -130,13 +130,13 @@ export async function authorizeSelectorCredential(input: { ...(input.scope.kind === 'workspace' ? { workspaceId: input.workspaceId } : {}), } ) - if (!access.ok || access.workspaceId !== input.workspaceId || !access.resolvedCredentialId) { + if (!access.ok || access.workspaceId !== input.workspaceId) { throw new SelectorConnectionUnavailableError() } input.protectedValues.add(access.resolvedCredentialId, 'reference') const providerId = await requireCredentialProviderBinding( - access.resolvedCredentialId, + suppliedId, access, input.policy.serviceIds ) diff --git a/apps/sim/tools/index.test.ts b/apps/sim/tools/index.test.ts index 4636bedf0a5..1cb84748caa 100644 --- a/apps/sim/tools/index.test.ts +++ b/apps/sim/tools/index.test.ts @@ -3176,51 +3176,6 @@ describe('Internal Route Trust', () => { } }) - it('unconditionally overwrites a caller-supplied hidden credential value', async () => { - const toolId = 'test_hidden_credential_authority' - const mockTool = { - id: toolId, - name: 'Hidden Credential Authority Test', - description: 'Verifies authoritative hidden credential injection', - version: '1.0.0', - oauth: { - required: true, - provider: 'google', - credentialKind: 'oauth' as const, - }, - params: { - accessToken: { type: 'string', required: true, visibility: 'hidden' }, - }, - request: { - url: () => 'https://www.googleapis.com/test', - method: 'GET' as const, - headers: (params: Record) => ({ - Authorization: `Bearer ${params.accessToken}`, - }), - }, - transformResponse: vi.fn().mockResolvedValue({ success: true, output: {} }), - } - ;(tools as Record)[toolId] = mockTool - mockResolveExecutorCredentialToken.mockResolvedValue({ - accessToken: 'authorized-value', - credentialType: 'oauth', - }) - - try { - const result = await executeTool(toolId, { - credential: 'selected-credential', - accessToken: 'caller-forged-value', - }) - - expect(result.success).toBe(true) - const requestOptions = mockSecureFetchWithPinnedIP.mock.calls.at(-1)?.[2] - expect(requestOptions?.headers.authorization).toBe('Bearer authorized-value') - expect(JSON.stringify(requestOptions)).not.toContain('caller-forged-value') - } finally { - Reflect.deleteProperty(tools, toolId) - } - }) - it('transports only active provenance selected for an internal model input', async () => { const registry = new ResolvedSecretTraceRegistry([ { From 1d1f77374ead680b0536bf0323d63c48f2d9028e Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Fri, 4 Sep 2026 09:40:04 -0700 Subject: [PATCH 13/29] chore(oci): minimize shared integration churn --- apps/docs/components/icons.tsx | 4 ++-- apps/docs/openapi-v2-resources.json | 2 +- .../connect-service-account-modal.tsx | 15 +++++++++++++++ apps/sim/components/icons.tsx | 4 ++-- apps/sim/lib/api/contracts/credentials.ts | 4 ++-- apps/sim/lib/api/contracts/v2/credentials.ts | 4 ++-- .../sim/lib/credentials/service-account-fields.ts | 2 +- 7 files changed, 25 insertions(+), 10 deletions(-) diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index dbea6ed5669..8bb890e80ea 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -9394,7 +9394,7 @@ export function NewRelicIcon(props: SVGProps) { ) } -export function OracleIcon(props: SVGProps) { +export function NetSuiteIcon(props: SVGProps) { return ( ) { ) } -export const NetSuiteIcon = OracleIcon +export const OracleIcon = NetSuiteIcon export function WizaIcon(props: SVGProps) { return ( diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index fc24556efc1..fcf2d2be427 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -9386,7 +9386,7 @@ "writeOnly": true, "type": "string", "minLength": 1, - "maxLength": 65536 + "maxLength": 8192 }, "username": { "description": "Provider run-as username.", diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx index 4ab50999337..5a0157783c1 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx @@ -49,6 +49,7 @@ export type ServiceAccountProviderId = | TokenServiceAccountProviderId | ClientCredentialAccountProviderId +/** Sim setup guides for each provider, docked bottom-left of each modal. */ const GOOGLE_SERVICE_ACCOUNT_DOCS_URL = 'https://docs.sim.ai/integrations/google-service-account' const ATLASSIAN_SERVICE_ACCOUNT_DOCS_URL = 'https://docs.sim.ai/integrations/atlassian-service-account' @@ -128,6 +129,20 @@ interface ConnectServiceAccountModalProps { onCreated?: (credentialId: string) => void } +/** + * Connect-service-account modal mounted from the per-integration detail page. + * Self-contained: takes the resolved SA provider + service metadata from the + * caller and submits via `useCreateWorkspaceCredential`. Branches the body + * based on `serviceAccountProviderId`: + * + * - `google-service-account`: JSON-paste + drag/drop. Validated client-side + * against {@link serviceAccountJsonSchema} before submitting. + * - `atlassian-service-account`: API token + site domain. Validated by the + * server against the Atlassian API; user-facing errors are mapped from the + * route's `error.code`. + * - `oci-api-key-service-account`: API signing-key fields. Validated locally + * and with a bounded OCI request before encrypted storage. + */ export function ConnectServiceAccountModal({ open, onOpenChange, diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index dbea6ed5669..8bb890e80ea 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -9394,7 +9394,7 @@ export function NewRelicIcon(props: SVGProps) { ) } -export function OracleIcon(props: SVGProps) { +export function NetSuiteIcon(props: SVGProps) { return ( ) { ) } -export const NetSuiteIcon = OracleIcon +export const OracleIcon = NetSuiteIcon export function WizaIcon(props: SVGProps) { return ( diff --git a/apps/sim/lib/api/contracts/credentials.ts b/apps/sim/lib/api/contracts/credentials.ts index 6f116135e30..dd7146b8428 100644 --- a/apps/sim/lib/api/contracts/credentials.ts +++ b/apps/sim/lib/api/contracts/credentials.ts @@ -158,7 +158,7 @@ export const createCredentialBodySchema = z */ authMethod: z.string().trim().min(1).max(64).optional(), /** PEM private key for certificate/JWT-based grants (for example Salesforce or NetSuite). */ - privateKey: z.string().trim().min(1).max(65_536).optional(), + privateKey: z.string().trim().min(1).max(8192).optional(), /** Run-as username for key-based grants (Salesforce JWT `sub`). */ username: z.string().trim().min(1).max(255).optional(), tenancyOcid: z.string().trim().min(1).max(255).optional(), @@ -245,7 +245,7 @@ export const updateCredentialByIdBodySchema = z orgId: z.string().trim().min(1).max(255).optional(), dataCenter: z.string().trim().min(1).max(32).optional(), authMethod: z.string().trim().min(1).max(64).optional(), - privateKey: z.string().trim().min(1).max(65_536).optional(), + privateKey: z.string().trim().min(1).max(8192).optional(), username: z.string().trim().min(1).max(255).optional(), tenancyOcid: z.string().trim().min(1).max(255).optional(), userOcid: z.string().trim().min(1).max(255).optional(), diff --git a/apps/sim/lib/api/contracts/v2/credentials.ts b/apps/sim/lib/api/contracts/v2/credentials.ts index dd9785585bf..e6d0454fc85 100644 --- a/apps/sim/lib/api/contracts/v2/credentials.ts +++ b/apps/sim/lib/api/contracts/v2/credentials.ts @@ -458,7 +458,7 @@ const v2ServiceAccountCredentialFieldsSchema = z .string() .trim() .min(1) - .max(65_536) + .max(8192) .optional() .describe('Write-only PEM private key.') .meta({ writeOnly: true }), @@ -717,7 +717,7 @@ const v2ServiceAccountSecretFieldsShape = { .string() .trim() .min(1) - .max(65_536) + .max(8192) .optional() .describe('Write-only PEM private key.') .meta({ writeOnly: true }), diff --git a/apps/sim/lib/credentials/service-account-fields.ts b/apps/sim/lib/credentials/service-account-fields.ts index 31bea7ac61b..2552b48a522 100644 --- a/apps/sim/lib/credentials/service-account-fields.ts +++ b/apps/sim/lib/credentials/service-account-fields.ts @@ -35,7 +35,7 @@ export type ServiceAccountFieldId = * providers from descriptor fields, bespoke providers inline.) Token-paste * providers contribute their entries from * `TOKEN_SERVICE_ACCOUNT_REQUIRED_FIELDS`, client-credential providers from - * `CLIENT_CREDENTIAL_ACCOUNT_REQUIRED_FIELDS`; the three bespoke providers are + * `CLIENT_CREDENTIAL_ACCOUNT_REQUIRED_FIELDS`; the four bespoke providers are * declared here. */ export const SERVICE_ACCOUNT_REQUIRED_FIELDS: Record = { From 4c5f6ec5f5db43f731c09c5489fb2bbc767d741d Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Fri, 4 Sep 2026 09:49:22 -0700 Subject: [PATCH 14/29] fix(oci): preserve endpoint and failure invariants --- .../credentials/[credentialId]/route.test.ts | 2 +- ...oci-api-key-service-account.server.test.ts | 8 +++ .../service-account-secret.test.ts | 56 ++++++++++++------- .../lib/credentials/service-account-secret.ts | 8 ++- .../lib/internal/oci/client.server.test.ts | 32 ++++++++++- apps/sim/lib/internal/oci/client.server.ts | 3 +- apps/sim/lib/internal/oci/endpoints.test.ts | 10 +++- apps/sim/lib/internal/oci/endpoints.ts | 10 ++-- 8 files changed, 96 insertions(+), 33 deletions(-) diff --git a/apps/sim/app/api/v2/credentials/[credentialId]/route.test.ts b/apps/sim/app/api/v2/credentials/[credentialId]/route.test.ts index 4a314175549..05928f70247 100644 --- a/apps/sim/app/api/v2/credentials/[credentialId]/route.test.ts +++ b/apps/sim/app/api/v2/credentials/[credentialId]/route.test.ts @@ -127,7 +127,7 @@ describe('PATCH /api/v2/credentials/[credentialId]', () => { expect(body).not.toContain('MUST_NOT_LEAK_CIPHERTEXT') }) - it('forwards a complete OCI rotation tuple and preserves explicit passphrase clearing', async () => { + it('forwards a complete OCI rotation tuple with an omitted replacement passphrase', async () => { const request = patchRequest({ tenancyOcid: 'ocid1.tenancy.oc1..tenant', userOcid: 'ocid1.user.oc1..replacement', diff --git a/apps/sim/lib/credentials/oci-api-key-service-account.server.test.ts b/apps/sim/lib/credentials/oci-api-key-service-account.server.test.ts index e44ebd35323..8bbff69a754 100644 --- a/apps/sim/lib/credentials/oci-api-key-service-account.server.test.ts +++ b/apps/sim/lib/credentials/oci-api-key-service-account.server.test.ts @@ -181,6 +181,14 @@ describe('OCI API-key credential setup', () => { expect(dependencies.encryptSecret).not.toHaveBeenCalled() }) + it('preserves an encryption failure after successful provider verification', async () => { + const encryptionFailure = new Error('internal encryption failure') + dependencies.encryptSecret.mockRejectedValueOnce(encryptionFailure) + + await expect(verifyAndEncryptOciApiKeyCredential(fields())).rejects.toBe(encryptionFailure) + expect(dependencies.verifySetup).toHaveBeenCalledOnce() + }) + it('forwards cancellation and never encrypts an aborted verification', async () => { const controller = new AbortController() const reason = new DOMException('canceled', 'AbortError') diff --git a/apps/sim/lib/credentials/service-account-secret.test.ts b/apps/sim/lib/credentials/service-account-secret.test.ts index 40f76285b99..2ee671ca597 100644 --- a/apps/sim/lib/credentials/service-account-secret.test.ts +++ b/apps/sim/lib/credentials/service-account-secret.test.ts @@ -217,28 +217,44 @@ describe('verifyAndBuildServiceAccountSecret', () => { expect(mockVerifyAndEncryptOci).not.toHaveBeenCalled() }) - it('classifies OCI verification outages without exposing provider details', async () => { - const { OciCredentialVerificationError } = await import( - '@/lib/credentials/oci-api-key-service-account.server' - ) - mockVerifyAndEncryptOci.mockRejectedValue( - new OciCredentialVerificationError('service_unavailable') - ) + it.each(['service_unavailable', 'invalid_response'] as const)( + 'classifies OCI %s failures as provider outages without exposing provider details', + async (code) => { + const { OciCredentialVerificationError } = await import( + '@/lib/credentials/oci-api-key-service-account.server' + ) + mockVerifyAndEncryptOci.mockRejectedValue(new OciCredentialVerificationError(code)) - const failure = await verifyAndBuildServiceAccountSecret('oci-api-key-service-account', { - tenancyOcid: 'ocid1.tenancy.oc1..tenant', - userOcid: 'ocid1.user.oc1..principal', - fingerprint: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff', - privateKey: 'provider-secret-key', - region: 'us-ashburn-1', - }).catch((error: unknown) => error) + const failure = await verifyAndBuildServiceAccountSecret('oci-api-key-service-account', { + tenancyOcid: 'ocid1.tenancy.oc1..tenant', + userOcid: 'ocid1.user.oc1..principal', + fingerprint: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff', + privateKey: 'provider-secret-key', + region: 'us-ashburn-1', + }).catch((error: unknown) => error) - expect(failure).toBeInstanceOf(ServiceAccountSecretError) - expect(failure).toMatchObject({ - message: 'OCI is temporarily unavailable for credential verification', - providerErrorCode: 'provider_unavailable', - }) - expect(JSON.stringify(failure)).not.toContain('provider-secret-key') + expect(failure).toBeInstanceOf(ServiceAccountSecretError) + expect(failure).toMatchObject({ + message: 'OCI is temporarily unavailable for credential verification', + providerErrorCode: 'provider_unavailable', + }) + expect(JSON.stringify(failure)).not.toContain('provider-secret-key') + } + ) + + it('does not misclassify an internal OCI credential failure as rejected credentials', async () => { + const internalFailure = new Error('internal encryption failure') + mockVerifyAndEncryptOci.mockRejectedValue(internalFailure) + + await expect( + verifyAndBuildServiceAccountSecret('oci-api-key-service-account', { + tenancyOcid: 'ocid1.tenancy.oc1..tenant', + userOcid: 'ocid1.user.oc1..principal', + fingerprint: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff', + privateKey: 'provider-secret-key', + region: 'us-ashburn-1', + }) + ).rejects.toBe(internalFailure) }) it('rejects an unknown non-empty providerId instead of persisting it as Google', async () => { diff --git a/apps/sim/lib/credentials/service-account-secret.ts b/apps/sim/lib/credentials/service-account-secret.ts index ed750c97b0c..0cfb44fd29f 100644 --- a/apps/sim/lib/credentials/service-account-secret.ts +++ b/apps/sim/lib/credentials/service-account-secret.ts @@ -259,14 +259,16 @@ async function buildOciApiKeyServiceAccountSecret( } } catch (error) { if (error instanceof OciCredentialVerificationError) { + const providerUnavailable = + error.code === 'service_unavailable' || error.code === 'invalid_response' throw new ServiceAccountSecretError( - error.code === 'service_unavailable' + providerUnavailable ? 'OCI is temporarily unavailable for credential verification' : 'OCI rejected the API-key credential', - error.code === 'service_unavailable' ? 'provider_unavailable' : 'invalid_credentials' + providerUnavailable ? 'provider_unavailable' : 'invalid_credentials' ) } - throw new ServiceAccountSecretError('OCI API-key credential is invalid') + throw error } } diff --git a/apps/sim/lib/internal/oci/client.server.test.ts b/apps/sim/lib/internal/oci/client.server.test.ts index 5b11384fac4..cd367634809 100644 --- a/apps/sim/lib/internal/oci/client.server.test.ts +++ b/apps/sim/lib/internal/oci/client.server.test.ts @@ -583,10 +583,14 @@ describe('credential-bound OCI client', () => { }) it('discards provider messages and exposes only safe status and request IDs', async () => { + const opaqueProviderSecret = 'opaque-diagnostic-secret-7f3a' mocks.secureFetch.mockResolvedValueOnce( secureResponse({ status: 401, - body: JSON.stringify({ message: PRIVATE_KEY, nested: { authorization: 'secret' } }), + body: JSON.stringify({ + message: opaqueProviderSecret, + nested: { authorization: 'another-opaque-secret' }, + }), headers: { 'opc-request-id': 'request-401' }, }) ) @@ -607,7 +611,8 @@ describe('credential-bound OCI client', () => { status: 401, opcRequestId: 'request-401', }) - expect(JSON.stringify(failure)).not.toContain('BEGIN PRIVATE KEY') + expect(JSON.stringify(failure)).not.toContain(opaqueProviderSecret) + expect(JSON.stringify(failure)).not.toContain('another-opaque-secret') expect(JSON.stringify(failure)).not.toContain('authorization') }) @@ -785,6 +790,29 @@ describe('credential-bound OCI client', () => { expect(cancel).toHaveBeenCalled() }) + it('propagates caller abort while reading a failed response body', async () => { + const controller = new AbortController() + const cancel = vi.fn() + mocks.secureFetch.mockResolvedValueOnce({ + ...secureResponse({ status: 409 }), + headers: new Headers(), + body: new ReadableStream({ cancel }), + }) + const { client, endpoint } = await createPreparedClient() + const pending = client.request({ + endpoint, + method: 'GET', + encodedPath: '/v1/test', + signal: controller.signal, + timeoutMs: 10_000, + maxResponseBytes: 1024, + }) + await vi.waitFor(() => expect(mocks.secureFetch).toHaveBeenCalledOnce()) + controller.abort() + await expect(pending).rejects.toMatchObject({ code: 'aborted' }) + expect(cancel).toHaveBeenCalled() + }) + it('propagates caller abort during retry backoff', async () => { vi.useFakeTimers() mocks.backoff.mockReturnValue(1000) diff --git a/apps/sim/lib/internal/oci/client.server.ts b/apps/sim/lib/internal/oci/client.server.ts index 45f8082e204..dc87bab92e8 100644 --- a/apps/sim/lib/internal/oci/client.server.ts +++ b/apps/sim/lib/internal/oci/client.server.ts @@ -583,8 +583,9 @@ async function readFailureCode( if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined const code = (parsed as Record).code return typeof code === 'string' && code.length <= 128 ? code : undefined - } catch { + } catch (error) { await response.body?.cancel().catch(() => {}) + if (signal.aborted) throw toError(signal.reason ?? error) return undefined } } diff --git a/apps/sim/lib/internal/oci/endpoints.test.ts b/apps/sim/lib/internal/oci/endpoints.test.ts index 94a8a1ca4af..c8f79f6581f 100644 --- a/apps/sim/lib/internal/oci/endpoints.test.ts +++ b/apps/sim/lib/internal/oci/endpoints.test.ts @@ -56,13 +56,21 @@ describe('OCI endpoint policies', () => { it('freezes declarative policies and derives exact static origins', () => { expect(Object.isFrozen(staticPolicy)).toBe(true) - expect(resolveStaticOciEndpoint(staticPolicy, region)).toMatchObject({ + const endpoint = resolveStaticOciEndpoint(staticPolicy, region) + expect(endpoint).toMatchObject({ origin: 'https://identity.us-ashburn-1.oraclecloud.com', hostname: 'identity.us-ashburn-1.oraclecloud.com', serviceId: OCI_SERVICE_ID, serviceName: 'identity', provenance: 'static', }) + expect(Object.isFrozen(endpoint)).toBe(true) + expect(Object.isFrozen(endpoint.region)).toBe(true) + expect(Object.isFrozen(endpoint.region.realm)).toBe(true) + expect(Reflect.set(endpoint, 'origin', 'https://attacker.example')).toBe(false) + expect(Reflect.set(endpoint.region, 'id', 'attacker-region-1')).toBe(false) + expect(endpoint.origin).toBe('https://identity.us-ashburn-1.oraclecloud.com') + expect(endpoint.region.id).toBe('us-ashburn-1') }) it('accepts discovered resource hosts only beneath the declared service, region, and realm', () => { diff --git a/apps/sim/lib/internal/oci/endpoints.ts b/apps/sim/lib/internal/oci/endpoints.ts index e9e0ddc5154..4aad3fe065e 100644 --- a/apps/sim/lib/internal/oci/endpoints.ts +++ b/apps/sim/lib/internal/oci/endpoints.ts @@ -182,10 +182,10 @@ export function getOciRegion(regionId: string): OciRegion { ? REGION_REALMS[normalized as keyof typeof REGION_REALMS] : undefined if (!realmId) throw new Error('OCI region is not recognized') - return { + return Object.freeze({ id: normalized, - realm: { id: realmId, domain: REALM_DOMAINS[realmId] }, - } + realm: Object.freeze({ id: realmId, domain: REALM_DOMAINS[realmId] }), + }) } export function resolveEffectiveOciRegion(defaultRegion: string, override?: string): OciRegion { @@ -312,14 +312,14 @@ function validateOciOrigin(params: { if (!hostnameMatches) { throw new Error('OCI destination hostname is not owned by the requested service') } - return { + return Object.freeze({ origin: url.origin, hostname: url.hostname, serviceId: params.policy.serviceId, serviceName: params.policy.serviceName, region: knownRegion, provenance: params.provenance, - } as OciPreparedEndpoint + }) as OciPreparedEndpoint } /** Resolves a static policy exclusively from its service and validated region. */ From 2511962d79e1c3c472617b1ff9880669b4faf485 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Fri, 4 Sep 2026 10:09:13 -0700 Subject: [PATCH 15/29] fix(oci): enforce credential and endpoint boundaries --- .../connect-service-account-modal.tsx | 3 + ...oci-api-key-service-account.server.test.ts | 12 ++- .../oci-api-key-service-account.server.ts | 7 +- .../service-account-secret.test.ts | 23 +++++ .../lib/internal/oci/client.server.test.ts | 19 +++-- apps/sim/lib/internal/oci/client.server.ts | 1 + apps/sim/lib/internal/oci/endpoints.test.ts | 31 +++++-- apps/sim/lib/internal/oci/endpoints.ts | 33 +++++++- apps/sim/lib/oauth/token-resolution.test.ts | 83 ++++++++++++++++++- apps/sim/lib/oauth/token-resolution.ts | 77 +++++++++-------- 10 files changed, 234 insertions(+), 55 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx index 5a0157783c1..ccace8951c9 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx @@ -393,6 +393,9 @@ function OciApiKeyServiceAccountModal({ }} placeholder='-----BEGIN PRIVATE KEY-----' minHeight={120} + className={ + privateKey ? '[&_textarea:not(:focus)]:[-webkit-text-security:disc]' : undefined + } mono required /> diff --git a/apps/sim/lib/credentials/oci-api-key-service-account.server.test.ts b/apps/sim/lib/credentials/oci-api-key-service-account.server.test.ts index 8bbff69a754..5d64bef30df 100644 --- a/apps/sim/lib/credentials/oci-api-key-service-account.server.test.ts +++ b/apps/sim/lib/credentials/oci-api-key-service-account.server.test.ts @@ -111,12 +111,12 @@ describe('OCI API-key credential setup', () => { await expect( verifyAndEncryptOciApiKeyCredential(fields({ privateKey: encryptedPrivateKey })) - ).rejects.toThrow('private key or passphrase') + ).rejects.toEqual(new OciCredentialVerificationError('invalid_credentials')) await expect( verifyAndEncryptOciApiKeyCredential( fields({ privateKey: encryptedPrivateKey, privateKeyPassphrase: passphrase.trim() }) ) - ).rejects.toThrow('private key or passphrase') + ).rejects.toEqual(new OciCredentialVerificationError('invalid_credentials')) }) it('rejects malformed, non-RSA, and undersized keys before network or encryption', async () => { @@ -134,7 +134,9 @@ describe('OCI API-key credential setup', () => { }), ] for (const invalid of cases) { - await expect(verifyAndEncryptOciApiKeyCredential(invalid)).rejects.toThrow() + await expect(verifyAndEncryptOciApiKeyCredential(invalid)).rejects.toEqual( + new OciCredentialVerificationError('invalid_credentials') + ) } expect(dependencies.verifySetup).not.toHaveBeenCalled() expect(dependencies.encryptSecret).not.toHaveBeenCalled() @@ -153,7 +155,9 @@ describe('OCI API-key credential setup', () => { fields({ tenancyOcid: `ocid1.tenancy.oc1..${'a'.repeat(240)}` }), ] for (const invalid of invalidCases) { - await expect(verifyAndEncryptOciApiKeyCredential(invalid)).rejects.toThrow() + await expect(verifyAndEncryptOciApiKeyCredential(invalid)).rejects.toEqual( + new OciCredentialVerificationError('invalid_credentials') + ) } expect(dependencies.verifySetup).not.toHaveBeenCalled() expect(dependencies.encryptSecret).not.toHaveBeenCalled() diff --git a/apps/sim/lib/credentials/oci-api-key-service-account.server.ts b/apps/sim/lib/credentials/oci-api-key-service-account.server.ts index 4a1cb0e50c8..d076e6c185c 100644 --- a/apps/sim/lib/credentials/oci-api-key-service-account.server.ts +++ b/apps/sim/lib/credentials/oci-api-key-service-account.server.ts @@ -170,7 +170,12 @@ export async function verifyAndEncryptOciApiKeyCredential( fields: OciApiKeyCredentialFields, signal?: AbortSignal ): Promise<{ encryptedServiceAccountKey: string; userOcid: string }> { - const secret = buildSecret(fields) + let secret: OciApiKeyServiceAccountSecret + try { + secret = buildSecret(fields) + } catch { + throw new OciCredentialVerificationError('invalid_credentials') + } let responseBody: Uint8Array try { responseBody = await verifyOciApiKeyCredentialForSetup(JSON.stringify(secret), signal) diff --git a/apps/sim/lib/credentials/service-account-secret.test.ts b/apps/sim/lib/credentials/service-account-secret.test.ts index 2ee671ca597..9ee96d1a570 100644 --- a/apps/sim/lib/credentials/service-account-secret.test.ts +++ b/apps/sim/lib/credentials/service-account-secret.test.ts @@ -242,6 +242,29 @@ describe('verifyAndBuildServiceAccountSecret', () => { } ) + it('classifies local OCI field validation as rejected credentials', async () => { + const { OciCredentialVerificationError } = await import( + '@/lib/credentials/oci-api-key-service-account.server' + ) + mockVerifyAndEncryptOci.mockRejectedValue( + new OciCredentialVerificationError('invalid_credentials') + ) + + await expect( + verifyAndBuildServiceAccountSecret('oci-api-key-service-account', { + tenancyOcid: 'ocid1.tenancy.oc1..tenant', + userOcid: 'ocid1.user.oc1..principal', + fingerprint: 'invalid-fingerprint', + privateKey: 'invalid-key', + region: 'us-ashburn-1', + }) + ).rejects.toMatchObject({ + name: 'ServiceAccountSecretError', + message: 'OCI rejected the API-key credential', + providerErrorCode: 'invalid_credentials', + }) + }) + it('does not misclassify an internal OCI credential failure as rejected credentials', async () => { const internalFailure = new Error('internal encryption failure') mockVerifyAndEncryptOci.mockRejectedValue(internalFailure) diff --git a/apps/sim/lib/internal/oci/client.server.test.ts b/apps/sim/lib/internal/oci/client.server.test.ts index cd367634809..5db9d129079 100644 --- a/apps/sim/lib/internal/oci/client.server.test.ts +++ b/apps/sim/lib/internal/oci/client.server.test.ts @@ -78,7 +78,10 @@ import { OCI_SERVICE_ID } from '@/lib/oauth/types' // Fixed test material. The expected signatures were generated independently with // OpenSSL 3 against Oracle's Request Signatures specification (retrieved 2026-09-03): // https://docs.oracle.com/en-us/iaas/Content/API/Concepts/signingrequests.htm -// The canonical header order is cross-checked against oci-common 2.140.0. +// The Identity hostname is cross-checked against Oracle's API endpoint catalog: +// https://docs.oracle.com/en-us/iaas/api/ +// The canonical header order and hostname template are cross-checked against +// oci-common and oci-identity 2.140.1. // Keep the synthetic fixture's PEM delimiters split so secret scanners do not // mistake checked-in conformance material for a deployable credential. const PRIVATE_KEY = `${['-----BEGIN', 'PRIVATE KEY-----'].join(' ')} @@ -128,6 +131,7 @@ const SECRET = JSON.stringify({ const STATIC_POLICY = createOciStaticEndpointPolicy({ serviceId: OCI_SERVICE_ID, serviceName: 'identity', + hostnameTemplate: 'regional-oci', }) function secureResponse(params: { @@ -239,6 +243,7 @@ describe('credential-bound OCI client', () => { const wrongPolicy = createOciStaticEndpointPolicy({ serviceId: 'slack', serviceName: 'identity', + hostnameTemplate: 'regional-oci', }) await expect(client.prepareStaticEndpoint(wrongPolicy)).rejects.toMatchObject({ code: 'invalid_endpoint', @@ -252,7 +257,7 @@ describe('credential-bound OCI client', () => { code: 'invalid_endpoint', }) expect((await createPreparedClient({ region: 'eu-frankfurt-1' })).endpoint.origin).toBe( - 'https://identity.eu-frankfurt-1.oraclecloud.com' + 'https://identity.eu-frankfurt-1.oci.oraclecloud.com' ) }) @@ -274,7 +279,7 @@ describe('credential-bound OCI client', () => { const authorization = authorizationFromLastRequest() expect(authorization).toBe( - 'Signature version="1",keyId="ocid1.tenancy.oc1..aaaaaaaafixedvector/ocid1.user.oc1..aaaaaaaafixedvector/25:53:22:62:aa:db:ff:ef:f5:77:08:d1:a2:ed:8b:e6",algorithm="rsa-sha256",headers="x-date (request-target) host",signature="pcMhip57/dPnKl/dfg5usN7oT/illXEGUp9Oj2d9bpGb0aRMBJclgVFKRYdYXciUGPM/9vKluD5/eGPBO1Oh7w/6NCB8UX2Ejh/lw8merU1QalZ/OfHyj+wKNVOpqwQjNqettRUzSVMhCqImDnvgx8ygmVCvdc0CeLXf2ZF9iT1bYlDjOiuxOcWreN2rs1ZmfLCfal204nAjrNAvoBSgHCPVquAYnfsT2auOWP4QeHN/Hd/v7TvNqsWBFIaLCyWZOvRzpsw/ZLgLzB+jkuPTdL7l4hOZATUd7xy1QPFTJ0P1RlLHjZE1sH7hbrqVGORNXrVhA1LaArObz6GWPOOghA=="' + 'Signature version="1",keyId="ocid1.tenancy.oc1..aaaaaaaafixedvector/ocid1.user.oc1..aaaaaaaafixedvector/25:53:22:62:aa:db:ff:ef:f5:77:08:d1:a2:ed:8b:e6",algorithm="rsa-sha256",headers="x-date (request-target) host",signature="szHTszQxwI2ewdVaeTurJY0ObT7qSjjTpXKLDRhnBp8g2hT1r2yxs4IaxN+wcrebh4i5tQYq5aBIuM3f5jOe4ng/e9+HCV+J8kHyRMxwk1b3nkqtImf8sPetp1ohD1XeWdT1gw5MSavC/C2mdHdDNlOrYAKD2vwxsKRbS6/C6ngRRcTispz6UU/ydmeYq3JjuFJezFPGWXRdqndM0dC+/ew19x08X/M6quZcxn9JZVw1E2YzSjq8xquLQYyISesVtpN81HEZ9KE9UOhbALNQAJcLCt6R3Su78aOR0S0vh19YkrwxCLbbTmPrVubksXsfZPcotbZmtXVIzNdLW0JpNg=="' ) const signature = /signature="([^"]+)"/.exec(authorization)?.[1] @@ -282,7 +287,7 @@ describe('credential-bound OCI client', () => { expect( verify( 'RSA-SHA256', - 'x-date: Thu, 03 Sep 2026 19:00:00 GMT\n(request-target): get /20160918/users?limit=10&name=Team%20X\nhost: identity.us-ashburn-1.oraclecloud.com', + 'x-date: Thu, 03 Sep 2026 19:00:00 GMT\n(request-target): get /20160918/users?limit=10&name=Team%20X\nhost: identity.us-ashburn-1.oci.oraclecloud.com', createPublicKey(PRIVATE_KEY), Buffer.from(signature!, 'base64') ) @@ -304,7 +309,7 @@ describe('credential-bound OCI client', () => { }) expect(authorizationFromLastRequest()).toBe( - 'Signature version="1",keyId="ocid1.tenancy.oc1..aaaaaaaafixedvector/ocid1.user.oc1..aaaaaaaafixedvector/25:53:22:62:aa:db:ff:ef:f5:77:08:d1:a2:ed:8b:e6",algorithm="rsa-sha256",headers="x-date (request-target) host content-type content-length x-content-sha256",signature="vyhrwd21evtwFet82VT1FvKEeZV+JSa3VZuS5p4Pj8K2zeU88GO+tGx/voUK9TFHijF7eG5gGS6WWc6tigrByTocbVOHpLtPNgBo2+1NbTbGHGUZIzCOR5CZ1ite74Ak43xZjyKBm+vZHrvS22leVOJe43V/HjqCxqyPn3WkKd7npqo9eFM1sibdj1h3Cmi79b5nXSPFe5KE+rnMRPTOB4nl7iFELvubg/Y7Y8w5hRYEe13w09zw9tTBdGJtZIuMoYwZYzPdZo5wbrN5WM6ylHC2euVh2PSazZZU99q55uhxiR6OaCQWLM0buytCqja8FeiEY8Iw3GuEbKUECKaM8Q=="' + 'Signature version="1",keyId="ocid1.tenancy.oc1..aaaaaaaafixedvector/ocid1.user.oc1..aaaaaaaafixedvector/25:53:22:62:aa:db:ff:ef:f5:77:08:d1:a2:ed:8b:e6",algorithm="rsa-sha256",headers="x-date (request-target) host content-type content-length x-content-sha256",signature="W2/OGoa2XuOin6+CQt32/+/lAXG5PWoamkAHr/k84oCYGUuub2mEYw1z9p4gc6/GPgeZ30wVp4DNVLzOjup3nJir1WsEsYzAk27XAIRVjxiQ7oBzCccnSnB88KLeNz1NDz7r4QPQGxZ50MBQEe0C+DEH2P+utpfFN73o7GCUhIN9hb27COg4l7ffdSLgjBWPN/B4AiZXpjz3I/GRHo29otGAhZ3MiX10gJTjy+qeAchAfmXmTx/nJqNhF0Aj255+B2lepCrHdkpcBpiTs5E+ppE6VvML0ByQ9ZLzBISB4MBljuFyey6tnTkueT73fqjQyM/OT+aO9HrAlemc3HSAXA=="' ) expect(mocks.secureFetch.mock.calls[0][1].headers).toMatchObject({ 'content-length': '0', @@ -337,7 +342,7 @@ describe('credential-bound OCI client', () => { { body: Uint8Array; headers: Record }, ] expect(url).toBe( - 'https://identity.us-ashburn-1.oraclecloud.com/v1/%E2%98%83?z=last&a=&a=%20%21%27%28%29%2A' + 'https://identity.us-ashburn-1.oci.oraclecloud.com/v1/%E2%98%83?z=last&a=&a=%20%21%27%28%29%2A' ) expect([...options.body]).toEqual([...body]) expect(options.body).not.toBe(body) @@ -666,6 +671,7 @@ describe('credential-bound OCI client', () => { const policy = createOciDiscoveredEndpointPolicy({ serviceId: OCI_SERVICE_ID, serviceName: 'database', + hostnameTemplate: 'regional', responsePolicy: STATIC_POLICY, source: { kind: 'json', path: ['endpoint'] }, }) @@ -694,6 +700,7 @@ describe('credential-bound OCI client', () => { const otherPolicy = createOciStaticEndpointPolicy({ serviceId: OCI_SERVICE_ID, serviceName: 'compute', + hostnameTemplate: 'regional', }) const otherEndpoint = await first.client.prepareStaticEndpoint(otherPolicy) mocks.secureFetch.mockResolvedValueOnce( diff --git a/apps/sim/lib/internal/oci/client.server.ts b/apps/sim/lib/internal/oci/client.server.ts index dc87bab92e8..a97f40f9318 100644 --- a/apps/sim/lib/internal/oci/client.server.ts +++ b/apps/sim/lib/internal/oci/client.server.ts @@ -875,6 +875,7 @@ export async function verifyOciApiKeyCredentialForSetup( const policy = createOciStaticEndpointPolicy({ serviceId: OCI_SERVICE_ID, serviceName: 'objectstorage', + hostnameTemplate: 'regional', }) const endpoint = resolveStaticOciEndpoint(policy, resolveEffectiveOciRegion(material.region)) const url = buildRequestUrl(endpoint, '/n/', []) diff --git a/apps/sim/lib/internal/oci/endpoints.test.ts b/apps/sim/lib/internal/oci/endpoints.test.ts index c8f79f6581f..efdd0b9b389 100644 --- a/apps/sim/lib/internal/oci/endpoints.test.ts +++ b/apps/sim/lib/internal/oci/endpoints.test.ts @@ -17,10 +17,12 @@ import { OCI_SERVICE_ID } from '@/lib/oauth/types' const staticPolicy = createOciStaticEndpointPolicy({ serviceId: OCI_SERVICE_ID, serviceName: 'identity', + hostnameTemplate: 'regional-oci', }) const discoveryPolicy = createOciDiscoveredEndpointPolicy({ serviceId: OCI_SERVICE_ID, serviceName: 'database', + hostnameTemplate: 'regional', responsePolicy: staticPolicy, source: { kind: 'json', path: ['endpoint'] }, }) @@ -33,7 +35,12 @@ describe('OCI region registry', () => { expect(region.id).toBe(id) expect(region.realm.id).toMatch(/^oc\d+$/) expect(region.realm.domain).toMatch(/^(?:oraclecloud|oraclegovcloud)/) - expect(regionalOciHostname('identity', region)).toBe(`identity.${id}.${region.realm.domain}`) + expect(regionalOciHostname('identity', region, 'regional-oci')).toBe( + `identity.${id}.oci.${region.realm.domain}` + ) + expect(regionalOciHostname('objectstorage', region, 'regional')).toBe( + `objectstorage.${id}.${region.realm.domain}` + ) } }) @@ -58,8 +65,8 @@ describe('OCI endpoint policies', () => { expect(Object.isFrozen(staticPolicy)).toBe(true) const endpoint = resolveStaticOciEndpoint(staticPolicy, region) expect(endpoint).toMatchObject({ - origin: 'https://identity.us-ashburn-1.oraclecloud.com', - hostname: 'identity.us-ashburn-1.oraclecloud.com', + origin: 'https://identity.us-ashburn-1.oci.oraclecloud.com', + hostname: 'identity.us-ashburn-1.oci.oraclecloud.com', serviceId: OCI_SERVICE_ID, serviceName: 'identity', provenance: 'static', @@ -69,7 +76,7 @@ describe('OCI endpoint policies', () => { expect(Object.isFrozen(endpoint.region.realm)).toBe(true) expect(Reflect.set(endpoint, 'origin', 'https://attacker.example')).toBe(false) expect(Reflect.set(endpoint.region, 'id', 'attacker-region-1')).toBe(false) - expect(endpoint.origin).toBe('https://identity.us-ashburn-1.oraclecloud.com') + expect(endpoint.origin).toBe('https://identity.us-ashburn-1.oci.oraclecloud.com') expect(endpoint.region.id).toBe('us-ashburn-1') }) @@ -104,6 +111,7 @@ describe('OCI endpoint policies', () => { const policy = createOciDiscoveredEndpointPolicy({ serviceId: OCI_SERVICE_ID, serviceName: 'database', + hostnameTemplate: 'regional', responsePolicy: staticPolicy, source: { kind: 'header', name: 'Endpoint' }, allowRegionalHost: true, @@ -118,8 +126,19 @@ describe('OCI endpoint policies', () => { it('rejects malformed policy declarations and forged region mappings', () => { expect(() => - createOciStaticEndpointPolicy({ serviceId: OCI_SERVICE_ID, serviceName: 'bad.name' }) + createOciStaticEndpointPolicy({ + serviceId: OCI_SERVICE_ID, + serviceName: 'bad.name', + hostnameTemplate: 'regional', + }) ).toThrow('service name') + expect(() => + createOciStaticEndpointPolicy({ + serviceId: OCI_SERVICE_ID, + serviceName: 'identity', + hostnameTemplate: 'arbitrary' as never, + }) + ).toThrow('hostname template') expect(() => resolveStaticOciEndpoint(staticPolicy, { id: region.id, @@ -130,9 +149,11 @@ describe('OCI endpoint policies', () => { createOciDiscoveredEndpointPolicy({ serviceId: OCI_SERVICE_ID, serviceName: 'database', + hostnameTemplate: 'regional', responsePolicy: createOciStaticEndpointPolicy({ serviceId: 'slack', serviceName: 'identity', + hostnameTemplate: 'regional-oci', }), source: { kind: 'json', path: ['endpoint'] }, }) diff --git a/apps/sim/lib/internal/oci/endpoints.ts b/apps/sim/lib/internal/oci/endpoints.ts index 4aad3fe065e..80a1f0528f0 100644 --- a/apps/sim/lib/internal/oci/endpoints.ts +++ b/apps/sim/lib/internal/oci/endpoints.ts @@ -2,6 +2,7 @@ import { isIpLiteral, unwrapIpv6Brackets } from '@sim/security/ssrf' import type { OAuthService } from '@/lib/oauth/types' export type OciDestinationProvenance = 'static' | 'authenticated-discovery' +export type OciHostnameTemplate = 'regional' | 'regional-oci' export interface OciRealm { readonly id: string @@ -32,6 +33,7 @@ export interface OciStaticEndpointPolicy { readonly kind: 'static' readonly serviceId: OAuthService readonly serviceName: string + readonly hostnameTemplate: OciHostnameTemplate readonly [ociEndpointPolicyBrand]: true } @@ -43,6 +45,7 @@ export interface OciDiscoveredEndpointPolicy { readonly kind: 'authenticated-discovery' readonly serviceId: OAuthService readonly serviceName: string + readonly hostnameTemplate: OciHostnameTemplate readonly responsePolicy: OciEndpointPolicy readonly source: OciDiscoverySource readonly allowRegionalHost: boolean @@ -203,6 +206,12 @@ function assertServiceName(value: string): void { } } +function assertHostnameTemplate(value: OciHostnameTemplate): void { + if (value !== 'regional' && value !== 'regional-oci') { + throw new Error('OCI endpoint policy hostname template is invalid') + } +} + function assertDiscoverySource(source: OciDiscoverySource): void { if (source.kind === 'header') { if (!/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(source.name)) { @@ -227,12 +236,15 @@ function assertDiscoverySource(source: OciDiscoverySource): void { export function createOciStaticEndpointPolicy(params: { serviceId: OAuthService serviceName: string + hostnameTemplate: OciHostnameTemplate }): OciStaticEndpointPolicy { assertServiceName(params.serviceName) + assertHostnameTemplate(params.hostnameTemplate) return Object.freeze({ kind: 'static', serviceId: params.serviceId, serviceName: params.serviceName, + hostnameTemplate: params.hostnameTemplate, }) as OciStaticEndpointPolicy } @@ -240,11 +252,13 @@ export function createOciStaticEndpointPolicy(params: { export function createOciDiscoveredEndpointPolicy(params: { serviceId: OAuthService serviceName: string + hostnameTemplate: OciHostnameTemplate responsePolicy: OciEndpointPolicy source: OciDiscoverySource allowRegionalHost?: boolean }): OciDiscoveredEndpointPolicy { assertServiceName(params.serviceName) + assertHostnameTemplate(params.hostnameTemplate) assertDiscoverySource(params.source) if (params.responsePolicy.serviceId !== params.serviceId) { throw new Error('OCI discovery source policy must have the same owning service') @@ -257,15 +271,22 @@ export function createOciDiscoveredEndpointPolicy(params: { kind: 'authenticated-discovery', serviceId: params.serviceId, serviceName: params.serviceName, + hostnameTemplate: params.hostnameTemplate, responsePolicy: params.responsePolicy, source, allowRegionalHost: params.allowRegionalHost ?? false, }) as OciDiscoveredEndpointPolicy } -export function regionalOciHostname(serviceName: string, region: OciRegion): string { +export function regionalOciHostname( + serviceName: string, + region: OciRegion, + hostnameTemplate: OciHostnameTemplate +): string { assertServiceName(serviceName) - return `${serviceName}.${region.id}.${region.realm.domain}` + assertHostnameTemplate(hostnameTemplate) + const ociLabel = hostnameTemplate === 'regional-oci' ? '.oci' : '' + return `${serviceName}.${region.id}${ociLabel}.${region.realm.domain}` } function validateOciOrigin(params: { @@ -301,7 +322,11 @@ function validateOciOrigin(params: { ) { throw new Error('OCI destination must be an exact HTTPS origin with the default port') } - const regionalHostname = regionalOciHostname(params.policy.serviceName, knownRegion) + const regionalHostname = regionalOciHostname( + params.policy.serviceName, + knownRegion, + params.policy.hostnameTemplate + ) const hostnameMatches = params.provenance === 'static' ? url.hostname === regionalHostname @@ -327,7 +352,7 @@ export function resolveStaticOciEndpoint( policy: OciStaticEndpointPolicy, region: OciRegion ): OciPreparedEndpoint { - const hostname = regionalOciHostname(policy.serviceName, region) + const hostname = regionalOciHostname(policy.serviceName, region, policy.hostnameTemplate) return validateOciOrigin({ origin: `https://${hostname}`, policy, diff --git a/apps/sim/lib/oauth/token-resolution.test.ts b/apps/sim/lib/oauth/token-resolution.test.ts index e1cb1ff81b0..75195878f20 100644 --- a/apps/sim/lib/oauth/token-resolution.test.ts +++ b/apps/sim/lib/oauth/token-resolution.test.ts @@ -515,6 +515,12 @@ describe('resolveCredentialAccessToken', () => { mockGetServiceConfigByServiceId.mockReturnValue({ serviceAccountProviderId: 'different-provider', }) + mockAuthorizeCredentialUseForAuth.mockResolvedValue({ + ok: true, + requesterUserId: 'user-1', + workspaceId: 'ws-1', + resolvedCredentialId: 'credential-authoritative', + }) await expect( resolveCredentialAccessToken({ @@ -528,7 +534,82 @@ describe('resolveCredentialAccessToken', () => { status: 500, code: 'OCI_CREDENTIAL_TOOL_UNSUPPORTED', }) - expect(authenticate).not.toHaveBeenCalled() + expect(authenticate).toHaveBeenCalledTimes(1) + expect(mockAuthorizeCredentialUseForAuth).toHaveBeenCalledTimes(1) + expect(mockResolveOAuthAccountId).toHaveBeenCalledTimes(2) + expect(mockResolveServiceAccountToken).not.toHaveBeenCalled() + }) + + it('does not reveal unsupported OCI tool metadata before authorization', async () => { + mockResolveOAuthAccountId.mockResolvedValue({ + credentialType: 'service_account', + credentialId: 'credential-authoritative', + providerId: 'oci-api-key-service-account', + workspaceId: 'ws-1', + accountId: '', + usedCredentialTable: true, + }) + mockGetToolMetadata.mockReturnValue({ + oauth: { required: true, provider: 'oci', credentialKind: 'service-account' }, + }) + mockGetServiceConfigByServiceId.mockReturnValue({ + serviceAccountProviderId: 'different-provider', + }) + mockAuthorizeCredentialUseForAuth.mockResolvedValue({ + ok: false, + error: 'You do not have access to this credential.', + }) + + await expect( + resolveCredentialAccessToken({ + requestId: 'req-oci', + credentialId: 'credential-authoritative', + toolId: 'future_oci_tool', + authenticate, + }) + ).resolves.toEqual({ + ok: false, + status: 403, + error: 'You do not have access to this credential.', + }) + expect(authenticate).toHaveBeenCalledTimes(1) + expect(mockResolveOAuthAccountId).toHaveBeenCalledTimes(1) + expect(mockResolveServiceAccountToken).not.toHaveBeenCalled() + }) + + it('rejects an OAuth credential selected for an OCI service-account tool', async () => { + const oauthCredential = { + accountId: 'oauth-account', + workspaceId: 'ws-1', + usedCredentialTable: true, + } as const + mockResolveOAuthAccountId.mockResolvedValue(oauthCredential) + mockGetToolMetadata.mockReturnValue({ + oauth: { required: true, provider: 'oci', credentialKind: 'service-account' }, + }) + mockGetServiceConfigByServiceId.mockReturnValue({ + serviceAccountProviderId: 'oci-api-key-service-account', + }) + mockAuthorizeCredentialUseForAuth.mockResolvedValue({ + ok: true, + requesterUserId: 'user-1', + credentialOwnerUserId: 'owner-1', + workspaceId: 'ws-1', + resolvedCredentialId: 'oauth-credential', + }) + + await expect( + resolveCredentialAccessToken({ + requestId: 'req-oci', + credentialId: 'oauth-credential', + toolId: 'future_oci_tool', + authenticate, + }) + ).resolves.toEqual({ ok: false, status: 403, error: 'Unauthorized' }) + expect(authenticate).toHaveBeenCalledTimes(1) + expect(mockResolveOAuthAccountId).toHaveBeenCalledTimes(2) + expect(mockGetCredential).not.toHaveBeenCalled() + expect(mockRefreshTokenIfNeeded).not.toHaveBeenCalled() expect(mockResolveServiceAccountToken).not.toHaveBeenCalled() }) diff --git a/apps/sim/lib/oauth/token-resolution.ts b/apps/sim/lib/oauth/token-resolution.ts index 89b444bcb86..c822b06f132 100644 --- a/apps/sim/lib/oauth/token-resolution.ts +++ b/apps/sim/lib/oauth/token-resolution.ts @@ -64,6 +64,8 @@ export interface ResolveCredentialTokenInput { resolvedCredential: ResolvedCredential | null /** Trusted OCI provider binding derived from registered tool metadata. */ expectedServiceAccountProviderId?: typeof OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID + /** Rejects an OCI credential after authorization when the tool has no trusted OCI binding. */ + rejectUnexpectedOciServiceAccount?: boolean } export type ResolveCredentialTokenResult = @@ -253,39 +255,58 @@ export async function resolveCredentialToken( return { ok: false, status: 400, error: 'impersonateEmail must be a valid email address' } } - const resolved = input.resolvedCredential + let resolved = input.resolvedCredential const authz = await authorizeCredentialUseForAuth(auth, { credentialId, workflowId, callerUserId, }) - if (resolved?.credentialType === 'service_account' && resolved.credentialId) { + const expectsOciServiceAccount = + input.expectedServiceAccountProviderId === OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID + if (expectsOciServiceAccount || input.rejectUnexpectedOciServiceAccount) { if (!authz.ok) { return { ok: false, status: 403, error: authz.error || 'Unauthorized' } } - let serviceAccountCredentialId = resolved.credentialId - let serviceAccountProviderId = resolved.providerId - const saActorId = authz.requesterUserId - let saWorkspaceId = resolved.workspaceId ?? authz.workspaceId ?? null - - if (input.expectedServiceAccountProviderId === OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID) { - const authoritativeId = authz.resolvedCredentialId - if (!authoritativeId) return { ok: false, status: 403, error: 'Unauthorized' } - const authoritative = await resolveOAuthAccountId(authoritativeId) - if ( - authoritative?.credentialType !== 'service_account' || - authoritative.credentialId !== authoritativeId || - authoritative.providerId !== OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID - ) { - return { ok: false, status: 403, error: 'Unauthorized' } + const authoritativeId = authz.resolvedCredentialId + if (!authoritativeId) return { ok: false, status: 403, error: 'Unauthorized' } + + const authoritative = await resolveOAuthAccountId(authoritativeId) + const isAuthoritativeOciServiceAccount = + authoritative?.credentialType === 'service_account' && + authoritative.credentialId === authoritativeId && + authoritative.providerId === OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID + + if (expectsOciServiceAccount && !isAuthoritativeOciServiceAccount) { + return { ok: false, status: 403, error: 'Unauthorized' } + } + + if (input.rejectUnexpectedOciServiceAccount && isAuthoritativeOciServiceAccount) { + logger.error(`[${requestId}] Tool is not configured for OCI API-key credentials`) + return { + ok: false, + status: 500, + code: 'OCI_CREDENTIAL_TOOL_UNSUPPORTED', + error: 'This tool is not configured to use OCI API-key credentials', } - serviceAccountCredentialId = authoritativeId - serviceAccountProviderId = authoritative.providerId - saWorkspaceId = authz.workspaceId ?? null } + resolved = authoritative + } + + if (resolved?.credentialType === 'service_account' && resolved.credentialId) { + if (!authz.ok) { + return { ok: false, status: 403, error: authz.error || 'Unauthorized' } + } + + const serviceAccountCredentialId = resolved.credentialId + const serviceAccountProviderId = resolved.providerId + const saActorId = authz.requesterUserId + const saWorkspaceId = expectsOciServiceAccount + ? (authz.workspaceId ?? null) + : (resolved.workspaceId ?? authz.workspaceId ?? null) + try { const result = await resolveServiceAccountToken( serviceAccountCredentialId, @@ -427,23 +448,10 @@ export async function resolveCredentialAccessToken( const expectedServiceAccountProviderId = isOciServiceAccountTool ? OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID : undefined - - if ( + const rejectUnexpectedOciServiceAccount = resolved?.credentialType === 'service_account' && resolved.providerId === OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID && !isOciServiceAccountTool - ) { - logger.error(`[${requestId}] Tool is not configured for OCI API-key credentials`, { - toolId, - serviceId, - }) - return { - ok: false, - status: 500, - code: 'OCI_CREDENTIAL_TOOL_UNSUPPORTED', - error: 'This tool is not configured to use OCI API-key credentials', - } - } const auth = await input.authenticate() return resolveCredentialToken(auth, { @@ -461,6 +469,7 @@ export async function resolveCredentialAccessToken( auditRequest, resolvedCredential: resolved, expectedServiceAccountProviderId, + rejectUnexpectedOciServiceAccount, }) } From 75f37ee863e38172c26c61973f546b0d7216976e Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Fri, 4 Sep 2026 10:20:33 -0700 Subject: [PATCH 16/29] fix(oci): close transport review gaps --- .../lib/internal/oci/client.server.test.ts | 47 +++++++++++++++++++ apps/sim/lib/internal/oci/client.server.ts | 7 ++- apps/sim/lib/internal/oci/endpoints.test.ts | 13 ++++- apps/sim/lib/internal/oci/endpoints.ts | 8 ++-- 4 files changed, 67 insertions(+), 8 deletions(-) diff --git a/apps/sim/lib/internal/oci/client.server.test.ts b/apps/sim/lib/internal/oci/client.server.test.ts index 5db9d129079..211e255f8fa 100644 --- a/apps/sim/lib/internal/oci/client.server.test.ts +++ b/apps/sim/lib/internal/oci/client.server.test.ts @@ -587,6 +587,22 @@ describe('credential-bound OCI client', () => { expect(mocks.secureFetch).toHaveBeenCalledOnce() }) + it('retries the bounded transport timeout', async () => { + mocks.secureFetch + .mockRejectedValueOnce(new Error('Request timed out after 9000ms')) + .mockResolvedValueOnce(secureResponse({ status: 200 })) + const { client, endpoint } = await createPreparedClient() + await client.request({ + endpoint, + method: 'GET', + encodedPath: '/v1/test', + retry: { kind: 'safe', maxAttempts: 2 }, + timeoutMs: 10_000, + maxResponseBytes: 1024, + }) + expect(mocks.secureFetch).toHaveBeenCalledTimes(2) + }) + it('discards provider messages and exposes only safe status and request IDs', async () => { const opaqueProviderSecret = 'opaque-diagnostic-secret-7f3a' mocks.secureFetch.mockResolvedValueOnce( @@ -729,6 +745,37 @@ describe('credential-bound OCI client', () => { ).rejects.toMatchObject({ code: 'invalid_endpoint' }) }) + it('prepares discovery from the retained safe Location header', async () => { + const policy = createOciDiscoveredEndpointPolicy({ + serviceId: OCI_SERVICE_ID, + serviceName: 'database', + hostnameTemplate: 'regional', + responsePolicy: STATIC_POLICY, + source: { kind: 'header', name: 'location' }, + }) + const { client, endpoint } = await createPreparedClient() + mocks.secureFetch.mockResolvedValueOnce( + secureResponse({ + headers: { + location: 'https://resource.database.us-ashburn-1.oraclecloud.com', + 'x-provider-secret': 'hidden', + }, + }) + ) + const response = await client.request({ + endpoint, + method: 'GET', + encodedPath: '/v1/test', + responseHeaders: ['location'], + timeoutMs: 10_000, + maxResponseBytes: 1024, + }) + expect((await client.prepareDiscoveredEndpoint(policy, response)).origin).toBe( + 'https://resource.database.us-ashburn-1.oraclecloud.com' + ) + expect(response.headers).not.toHaveProperty('x-provider-secret') + }) + it('propagates caller abort without leaking a transport failure', async () => { const controller = new AbortController() mocks.secureFetch.mockImplementationOnce( diff --git a/apps/sim/lib/internal/oci/client.server.ts b/apps/sim/lib/internal/oci/client.server.ts index a97f40f9318..be75af615a3 100644 --- a/apps/sim/lib/internal/oci/client.server.ts +++ b/apps/sim/lib/internal/oci/client.server.ts @@ -591,9 +591,12 @@ async function readFailureCode( } function isRetryableTransportFailure(error: unknown): boolean { - if (!error || typeof error !== 'object') return false + if (!(error instanceof Error)) return false const code = (error as { code?: unknown }).code - return typeof code === 'string' && RETRYABLE_TRANSPORT_CODES.has(code) + return ( + (typeof code === 'string' && RETRYABLE_TRANSPORT_CODES.has(code)) || + /^Request timed out after \d+ms$/.test(error.message) + ) } function extractDiscoveredOrigin( diff --git a/apps/sim/lib/internal/oci/endpoints.test.ts b/apps/sim/lib/internal/oci/endpoints.test.ts index efdd0b9b389..5f74df53478 100644 --- a/apps/sim/lib/internal/oci/endpoints.test.ts +++ b/apps/sim/lib/internal/oci/endpoints.test.ts @@ -113,14 +113,14 @@ describe('OCI endpoint policies', () => { serviceName: 'database', hostnameTemplate: 'regional', responsePolicy: staticPolicy, - source: { kind: 'header', name: 'Endpoint' }, + source: { kind: 'header', name: 'location' }, allowRegionalHost: true, }) expect( resolveDiscoveredOciEndpoint(policy, region, 'https://database.us-ashburn-1.oraclecloud.com') .origin ).toBe('https://database.us-ashburn-1.oraclecloud.com') - expect(policy.source).toEqual({ kind: 'header', name: 'endpoint' }) + expect(policy.source).toEqual({ kind: 'header', name: 'location' }) expect(Object.isFrozen(policy.source)).toBe(true) }) @@ -158,5 +158,14 @@ describe('OCI endpoint policies', () => { source: { kind: 'json', path: ['endpoint'] }, }) ).toThrow('same owning service') + expect(() => + createOciDiscoveredEndpointPolicy({ + serviceId: OCI_SERVICE_ID, + serviceName: 'database', + hostnameTemplate: 'regional', + responsePolicy: staticPolicy, + source: { kind: 'header', name: 'x-custom-endpoint' } as never, + }) + ).toThrow('safe Location') }) }) diff --git a/apps/sim/lib/internal/oci/endpoints.ts b/apps/sim/lib/internal/oci/endpoints.ts index 80a1f0528f0..21ec7395b7f 100644 --- a/apps/sim/lib/internal/oci/endpoints.ts +++ b/apps/sim/lib/internal/oci/endpoints.ts @@ -38,7 +38,7 @@ export interface OciStaticEndpointPolicy { } export type OciDiscoverySource = - | { readonly kind: 'header'; readonly name: string } + | { readonly kind: 'header'; readonly name: 'location' } | { readonly kind: 'json'; readonly path: readonly string[] } export interface OciDiscoveredEndpointPolicy { @@ -214,8 +214,8 @@ function assertHostnameTemplate(value: OciHostnameTemplate): void { function assertDiscoverySource(source: OciDiscoverySource): void { if (source.kind === 'header') { - if (!/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(source.name)) { - throw new Error('OCI discovery header name is invalid') + if (source.name !== 'location') { + throw new Error('OCI discovery header must be the safe Location response header') } return } @@ -266,7 +266,7 @@ export function createOciDiscoveredEndpointPolicy(params: { const source = params.source.kind === 'json' ? Object.freeze({ ...params.source, path: Object.freeze([...params.source.path]) }) - : Object.freeze({ ...params.source, name: params.source.name.toLowerCase() }) + : Object.freeze({ ...params.source }) return Object.freeze({ kind: 'authenticated-discovery', serviceId: params.serviceId, From 476a07a668e60c048118ae5be8b47c496348b428 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Fri, 4 Sep 2026 10:29:54 -0700 Subject: [PATCH 17/29] fix(oci): enforce destination validation deadlines --- .../lib/internal/oci/client.server.test.ts | 46 +++++--- apps/sim/lib/internal/oci/client.server.ts | 105 +++++++++++------- 2 files changed, 96 insertions(+), 55 deletions(-) diff --git a/apps/sim/lib/internal/oci/client.server.test.ts b/apps/sim/lib/internal/oci/client.server.test.ts index 211e255f8fa..caaf406ac71 100644 --- a/apps/sim/lib/internal/oci/client.server.test.ts +++ b/apps/sim/lib/internal/oci/client.server.test.ts @@ -67,6 +67,7 @@ import { type OciAuthenticatedResponse, type OciClient, type OciRequest, + verifyOciApiKeyCredentialForSetup, } from '@/lib/internal/oci/client.server' import { createOciDiscoveredEndpointPolicy, @@ -778,14 +779,7 @@ describe('credential-bound OCI client', () => { it('propagates caller abort without leaking a transport failure', async () => { const controller = new AbortController() - mocks.secureFetch.mockImplementationOnce( - (_url: string, options: { signal: AbortSignal }) => - new Promise((_resolve, reject) => { - options.signal.addEventListener('abort', () => reject(options.signal.reason), { - once: true, - }) - }) - ) + mocks.secureFetch.mockImplementationOnce(() => new Promise(() => {})) const { client, endpoint } = await createPreparedClient() const pending = client.request({ endpoint, @@ -801,14 +795,7 @@ describe('credential-bound OCI client', () => { it('applies one deadline to in-flight transport work', async () => { vi.useFakeTimers() - mocks.secureFetch.mockImplementationOnce( - (_url: string, options: { signal: AbortSignal }) => - new Promise((_resolve, reject) => { - options.signal.addEventListener('abort', () => reject(options.signal.reason), { - once: true, - }) - }) - ) + mocks.secureFetch.mockImplementationOnce(() => new Promise(() => {})) const { client, endpoint } = await createPreparedClient() const pending = client.request({ endpoint, @@ -822,6 +809,33 @@ describe('credential-bound OCI client', () => { await assertion }) + it('propagates caller abort while setup destination validation is pending', async () => { + const controller = new AbortController() + mocks.secureFetch.mockImplementationOnce(() => new Promise(() => {})) + const pending = verifyOciApiKeyCredentialForSetup(SECRET, controller.signal) + await vi.waitFor(() => expect(mocks.secureFetch).toHaveBeenCalledOnce()) + controller.abort() + await expect(pending).rejects.toMatchObject({ code: 'aborted' }) + }) + + it('does not start setup destination validation after an earlier caller abort', async () => { + const controller = new AbortController() + controller.abort() + await expect( + verifyOciApiKeyCredentialForSetup(SECRET, controller.signal) + ).rejects.toMatchObject({ code: 'aborted' }) + expect(mocks.secureFetch).not.toHaveBeenCalled() + }) + + it('applies the setup deadline while destination validation is pending', async () => { + vi.useFakeTimers() + mocks.secureFetch.mockImplementationOnce(() => new Promise(() => {})) + const pending = verifyOciApiKeyCredentialForSetup(SECRET) + const assertion = expect(pending).rejects.toMatchObject({ code: 'deadline_exceeded' }) + await vi.advanceTimersByTimeAsync(10_001) + await assertion + }) + it('applies the same deadline while reading the response body', async () => { vi.useFakeTimers() const cancel = vi.fn() diff --git a/apps/sim/lib/internal/oci/client.server.ts b/apps/sim/lib/internal/oci/client.server.ts index be75af615a3..db659a2ee6e 100644 --- a/apps/sim/lib/internal/oci/client.server.ts +++ b/apps/sim/lib/internal/oci/client.server.ts @@ -654,19 +654,38 @@ function createDeadline( } } -async function waitForRetry(delayMs: number, signal: AbortSignal): Promise { - if (signal.aborted) throw toError(signal.reason) - let rejectAbort: ((reason?: unknown) => void) | undefined - const aborted = new Promise((_, reject) => { - rejectAbort = reject +function raceWithAbort(operation: () => Promise, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.reject(toError(signal.reason)) + return new Promise((resolve, reject) => { + const cleanup = () => signal.removeEventListener('abort', onAbort) + const onAbort = () => { + cleanup() + reject(toError(signal.reason)) + } + signal.addEventListener('abort', onAbort, { once: true }) + let pending: Promise + try { + pending = operation() + } catch (error) { + cleanup() + reject(error) + return + } + pending.then( + (value) => { + cleanup() + resolve(value) + }, + (error: unknown) => { + cleanup() + reject(error) + } + ) }) - const onAbort = () => rejectAbort?.(signal.reason) - signal.addEventListener('abort', onAbort, { once: true }) - try { - await Promise.race([sleep(delayMs), aborted]) - } finally { - signal.removeEventListener('abort', onAbort) - } +} + +async function waitForRetry(delayMs: number, signal: AbortSignal): Promise { + await raceWithAbort(() => sleep(delayMs), signal) } /** Creates a lazily loaded OCI client bound to trusted workspace and service context. */ @@ -769,20 +788,24 @@ export async function createOciClient(params: CreateOciClientParams): Promise + secureFetchWithValidation( + signed.url, + { + method: request.method, + headers: { ...signed.headers }, + ...(signed.body !== undefined ? { body: new Uint8Array(signed.body) } : {}), + timeout: Math.max(1, Math.floor(remainingMs)), + maxResponseBytes: request.maxResponseBytes, + maxRedirects: 0, + signal: deadline.signal, + profile: 'configuredEndpoint', + logUrlValidationDetails: false, + }, + 'OCI destination' + ), + deadline.signal ) } catch (error) { if (deadline.signal.aborted) { @@ -891,19 +914,23 @@ export async function verifyOciApiKeyCredentialForSetup( headers: { accept: 'application/json' }, signingDate: new Date(), }) - const response = await secureFetchWithValidation( - signed.url, - { - method: 'GET', - headers: { ...signed.headers }, - timeout: SETUP_VERIFICATION_TIMEOUT_MS, - maxResponseBytes: SETUP_VERIFICATION_RESPONSE_BYTES, - maxRedirects: 0, - signal: deadline.signal, - profile: 'configuredEndpoint', - logUrlValidationDetails: false, - }, - 'OCI credential verification destination' + const response = await raceWithAbort( + () => + secureFetchWithValidation( + signed.url, + { + method: 'GET', + headers: { ...signed.headers }, + timeout: SETUP_VERIFICATION_TIMEOUT_MS, + maxResponseBytes: SETUP_VERIFICATION_RESPONSE_BYTES, + maxRedirects: 0, + signal: deadline.signal, + profile: 'configuredEndpoint', + logUrlValidationDetails: false, + }, + 'OCI credential verification destination' + ), + deadline.signal ) if (!response.ok) { await readFailureCode(response, deadline.signal) From 0d2e2541b8c38cb5ff69bf2c9497bd86ed471bb1 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Fri, 4 Sep 2026 10:39:15 -0700 Subject: [PATCH 18/29] fix(oci): stop requests after DNS deadlines --- .../lib/internal/oci/client.server.test.ts | 79 +++++++++++++++--- apps/sim/lib/internal/oci/client.server.ts | 81 +++++++++++-------- 2 files changed, 115 insertions(+), 45 deletions(-) diff --git a/apps/sim/lib/internal/oci/client.server.test.ts b/apps/sim/lib/internal/oci/client.server.test.ts index caaf406ac71..0cd83de9fc7 100644 --- a/apps/sim/lib/internal/oci/client.server.test.ts +++ b/apps/sim/lib/internal/oci/client.server.test.ts @@ -10,6 +10,7 @@ const mocks = vi.hoisted(() => ({ predicates: undefined as unknown, rows: [] as { encryptedServiceAccountKey: string | null }[], secureFetch: vi.fn(), + validateUrl: vi.fn(), })) vi.mock('@sim/db', () => ({ @@ -44,7 +45,8 @@ vi.mock('@/lib/core/security/encryption', () => ({ decryptSecret: mocks.decryptS vi.mock('@/lib/core/security/input-validation.server', () => ({ DEFAULT_MAX_RESPONSE_BYTES: 100 * 1024 * 1024, - secureFetchWithValidation: mocks.secureFetch, + secureFetchWithPinnedIP: mocks.secureFetch, + validateUrlWithDNS: mocks.validateUrl, })) vi.mock('@sim/utils/retry', () => ({ @@ -176,7 +178,7 @@ async function createPreparedClient(params: { region?: string } = {}): Promise<{ } function authorizationFromLastRequest(): string { - const options = mocks.secureFetch.mock.calls.at(-1)?.[1] as { headers: Record } + const options = mocks.secureFetch.mock.calls.at(-1)?.[2] as { headers: Record } return options.headers.authorization } @@ -187,6 +189,11 @@ describe('credential-bound OCI client', () => { mocks.decryptSecret.mockReset().mockResolvedValue({ decrypted: SECRET }) mocks.backoff.mockReset().mockReturnValue(0) mocks.secureFetch.mockReset().mockResolvedValue(secureResponse({})) + mocks.validateUrl.mockReset().mockResolvedValue({ + isValid: true, + resolvedIP: '203.0.113.10', + originalHostname: 'identity.us-ashburn-1.oci.oraclecloud.com', + }) }) afterEach(() => { @@ -312,7 +319,7 @@ describe('credential-bound OCI client', () => { expect(authorizationFromLastRequest()).toBe( 'Signature version="1",keyId="ocid1.tenancy.oc1..aaaaaaaafixedvector/ocid1.user.oc1..aaaaaaaafixedvector/25:53:22:62:aa:db:ff:ef:f5:77:08:d1:a2:ed:8b:e6",algorithm="rsa-sha256",headers="x-date (request-target) host content-type content-length x-content-sha256",signature="W2/OGoa2XuOin6+CQt32/+/lAXG5PWoamkAHr/k84oCYGUuub2mEYw1z9p4gc6/GPgeZ30wVp4DNVLzOjup3nJir1WsEsYzAk27XAIRVjxiQ7oBzCccnSnB88KLeNz1NDz7r4QPQGxZ50MBQEe0C+DEH2P+utpfFN73o7GCUhIN9hb27COg4l7ffdSLgjBWPN/B4AiZXpjz3I/GRHo29otGAhZ3MiX10gJTjy+qeAchAfmXmTx/nJqNhF0Aj255+B2lepCrHdkpcBpiTs5E+ppE6VvML0ByQ9ZLzBISB4MBljuFyey6tnTkueT73fqjQyM/OT+aO9HrAlemc3HSAXA=="' ) - expect(mocks.secureFetch.mock.calls[0][1].headers).toMatchObject({ + expect(mocks.secureFetch.mock.calls[0][2].headers).toMatchObject({ 'content-length': '0', 'content-type': 'application/json', 'x-content-sha256': '47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=', @@ -338,13 +345,15 @@ describe('credential-bound OCI client', () => { maxResponseBytes: 1024, }) - const [url, options] = mocks.secureFetch.mock.calls[0] as [ + const [url, resolvedIP, options] = mocks.secureFetch.mock.calls[0] as [ + string, string, { body: Uint8Array; headers: Record }, ] expect(url).toBe( 'https://identity.us-ashburn-1.oci.oraclecloud.com/v1/%E2%98%83?z=last&a=&a=%20%21%27%28%29%2A' ) + expect(resolvedIP).toBe('203.0.113.10') expect([...options.body]).toEqual([...body]) expect(options.body).not.toBe(body) expect(options.headers).toMatchObject({ @@ -380,7 +389,7 @@ describe('credential-bound OCI client', () => { timeoutMs: 10_000, maxResponseBytes: 1024, }) - const options = mocks.secureFetch.mock.calls.at(-1)?.[1] + const options = mocks.secureFetch.mock.calls.at(-1)?.[2] expect(options.method).toBe(method) expect(options).not.toHaveProperty('body') expect(options.headers).not.toHaveProperty('content-length') @@ -410,7 +419,7 @@ describe('credential-bound OCI client', () => { timeoutMs: 10_000, maxResponseBytes: 1024, }) - expect(mocks.secureFetch.mock.calls.at(-1)?.[1].headers['content-length']).toBe('0') + expect(mocks.secureFetch.mock.calls.at(-1)?.[2].headers['content-length']).toBe('0') } ) @@ -515,8 +524,8 @@ describe('credential-bound OCI client', () => { maxResponseBytes: 1024, }) - const first = mocks.secureFetch.mock.calls[0][1] - const second = mocks.secureFetch.mock.calls[1][1] + const first = mocks.secureFetch.mock.calls[0][2] + const second = mocks.secureFetch.mock.calls[1][2] expect([...first.body]).toEqual([...second.body]) expect(first.headers['opc-retry-token']).toBe('operation-token') expect(second.headers['opc-retry-token']).toBe('operation-token') @@ -809,13 +818,45 @@ describe('credential-bound OCI client', () => { await assertion }) + it('does not start a pinned request when destination validation outlives the deadline', async () => { + vi.useFakeTimers() + let finishValidation: + | ((value: { isValid: true; resolvedIP: string; originalHostname: string }) => void) + | undefined + mocks.validateUrl.mockImplementationOnce( + () => + new Promise((resolve) => { + finishValidation = resolve + }) + ) + const { client, endpoint } = await createPreparedClient() + const pending = client.request({ + endpoint, + method: 'GET', + encodedPath: '/v1/test', + timeoutMs: 100, + maxResponseBytes: 1024, + }) + const assertion = expect(pending).rejects.toMatchObject({ code: 'deadline_exceeded' }) + await vi.advanceTimersByTimeAsync(101) + await assertion + finishValidation?.({ + isValid: true, + resolvedIP: '203.0.113.10', + originalHostname: 'identity.us-ashburn-1.oci.oraclecloud.com', + }) + await Promise.resolve() + expect(mocks.secureFetch).not.toHaveBeenCalled() + }) + it('propagates caller abort while setup destination validation is pending', async () => { const controller = new AbortController() - mocks.secureFetch.mockImplementationOnce(() => new Promise(() => {})) + mocks.validateUrl.mockImplementationOnce(() => new Promise(() => {})) const pending = verifyOciApiKeyCredentialForSetup(SECRET, controller.signal) - await vi.waitFor(() => expect(mocks.secureFetch).toHaveBeenCalledOnce()) + await vi.waitFor(() => expect(mocks.validateUrl).toHaveBeenCalledOnce()) controller.abort() await expect(pending).rejects.toMatchObject({ code: 'aborted' }) + expect(mocks.secureFetch).not.toHaveBeenCalled() }) it('does not start setup destination validation after an earlier caller abort', async () => { @@ -824,16 +865,32 @@ describe('credential-bound OCI client', () => { await expect( verifyOciApiKeyCredentialForSetup(SECRET, controller.signal) ).rejects.toMatchObject({ code: 'aborted' }) + expect(mocks.validateUrl).not.toHaveBeenCalled() expect(mocks.secureFetch).not.toHaveBeenCalled() }) it('applies the setup deadline while destination validation is pending', async () => { vi.useFakeTimers() - mocks.secureFetch.mockImplementationOnce(() => new Promise(() => {})) + let finishValidation: + | ((value: { isValid: true; resolvedIP: string; originalHostname: string }) => void) + | undefined + mocks.validateUrl.mockImplementationOnce( + () => + new Promise((resolve) => { + finishValidation = resolve + }) + ) const pending = verifyOciApiKeyCredentialForSetup(SECRET) const assertion = expect(pending).rejects.toMatchObject({ code: 'deadline_exceeded' }) await vi.advanceTimersByTimeAsync(10_001) await assertion + finishValidation?.({ + isValid: true, + resolvedIP: '203.0.113.10', + originalHostname: 'objectstorage.us-ashburn-1.oraclecloud.com', + }) + await Promise.resolve() + expect(mocks.secureFetch).not.toHaveBeenCalled() }) it('applies the same deadline while reading the response body', async () => { diff --git a/apps/sim/lib/internal/oci/client.server.ts b/apps/sim/lib/internal/oci/client.server.ts index db659a2ee6e..2e9fba7a889 100644 --- a/apps/sim/lib/internal/oci/client.server.ts +++ b/apps/sim/lib/internal/oci/client.server.ts @@ -15,8 +15,10 @@ import { and, eq } from 'drizzle-orm' import { decryptSecret } from '@/lib/core/security/encryption' import { DEFAULT_MAX_RESPONSE_BYTES, + type SecureFetchOptions, type SecureFetchResponse, - secureFetchWithValidation, + secureFetchWithPinnedIP, + validateUrlWithDNS, } from '@/lib/core/security/input-validation.server' import { DEFAULT_MAX_ERROR_BODY_BYTES, @@ -688,6 +690,23 @@ async function waitForRetry(delayMs: number, signal: AbortSignal): Promise await raceWithAbort(() => sleep(delayMs), signal) } +async function secureOciFetch( + url: string, + options: SecureFetchOptions, + paramName: string, + signal: AbortSignal +): Promise { + const validation = await raceWithAbort( + () => + validateUrlWithDNS(url, paramName, options.profile, { + logDetails: options.logUrlValidationDetails, + }), + signal + ) + if (!validation.isValid) throw new Error(validation.error) + return raceWithAbort(() => secureFetchWithPinnedIP(url, validation.resolvedIP, options), signal) +} + /** Creates a lazily loaded OCI client bound to trusted workspace and service context. */ export async function createOciClient(params: CreateOciClientParams): Promise { const service = getServiceConfigByServiceId(params.serviceId) @@ -788,23 +807,20 @@ export async function createOciClient(params: CreateOciClientParams): Promise - secureFetchWithValidation( - signed.url, - { - method: request.method, - headers: { ...signed.headers }, - ...(signed.body !== undefined ? { body: new Uint8Array(signed.body) } : {}), - timeout: Math.max(1, Math.floor(remainingMs)), - maxResponseBytes: request.maxResponseBytes, - maxRedirects: 0, - signal: deadline.signal, - profile: 'configuredEndpoint', - logUrlValidationDetails: false, - }, - 'OCI destination' - ), + response = await secureOciFetch( + signed.url, + { + method: request.method, + headers: { ...signed.headers }, + ...(signed.body !== undefined ? { body: new Uint8Array(signed.body) } : {}), + timeout: Math.max(1, Math.floor(remainingMs)), + maxResponseBytes: request.maxResponseBytes, + maxRedirects: 0, + signal: deadline.signal, + profile: 'configuredEndpoint', + logUrlValidationDetails: false, + }, + 'OCI destination', deadline.signal ) } catch (error) { @@ -914,22 +930,19 @@ export async function verifyOciApiKeyCredentialForSetup( headers: { accept: 'application/json' }, signingDate: new Date(), }) - const response = await raceWithAbort( - () => - secureFetchWithValidation( - signed.url, - { - method: 'GET', - headers: { ...signed.headers }, - timeout: SETUP_VERIFICATION_TIMEOUT_MS, - maxResponseBytes: SETUP_VERIFICATION_RESPONSE_BYTES, - maxRedirects: 0, - signal: deadline.signal, - profile: 'configuredEndpoint', - logUrlValidationDetails: false, - }, - 'OCI credential verification destination' - ), + const response = await secureOciFetch( + signed.url, + { + method: 'GET', + headers: { ...signed.headers }, + timeout: SETUP_VERIFICATION_TIMEOUT_MS, + maxResponseBytes: SETUP_VERIFICATION_RESPONSE_BYTES, + maxRedirects: 0, + signal: deadline.signal, + profile: 'configuredEndpoint', + logUrlValidationDetails: false, + }, + 'OCI credential verification destination', deadline.signal ) if (!response.ok) { From b22b106cab5d38a28e52545bce329541fd49705c Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Fri, 4 Sep 2026 12:25:45 -0700 Subject: [PATCH 19/29] refactor(oci): remove shared credential hardening --- .../orchestration/credential-create.ts | 18 +- .../credentials/orchestration/index.test.ts | 115 +++++++++++- .../lib/credentials/orchestration/index.ts | 30 +--- .../service-account-secret.test.ts | 16 +- .../lib/credentials/service-account-secret.ts | 56 ++---- apps/sim/lib/oauth/token-resolution.test.ts | 169 ------------------ apps/sim/lib/oauth/token-resolution.ts | 73 +------- 7 files changed, 166 insertions(+), 311 deletions(-) diff --git a/apps/sim/lib/credentials/orchestration/credential-create.ts b/apps/sim/lib/credentials/orchestration/credential-create.ts index ac8a95a1f59..5f13e631eb0 100644 --- a/apps/sim/lib/credentials/orchestration/credential-create.ts +++ b/apps/sim/lib/credentials/orchestration/credential-create.ts @@ -13,6 +13,7 @@ import { decryptSecret } from '@/lib/core/security/encryption' import { getCredentialActorContext, requireOrdinaryCredentialType } from '@/lib/credentials/access' import { AtlassianValidationError } from '@/lib/credentials/atlassian-service-account' import { getCredentialCreationWorkspaceContext } from '@/lib/credentials/environment' +import { OciCredentialVerificationError } from '@/lib/credentials/oci-api-key-service-account.server' import type { CredentialOrchestrationErrorCode } from '@/lib/credentials/orchestration' import { ServiceAccountSecretError, @@ -295,10 +296,7 @@ export async function createCredentialRecord( Object.assign(extraAuditMetadata, secret.auditMetadata) } catch (error) { if (error instanceof ServiceAccountSecretError) { - return failure(error.message, 'validation', { - providerErrorCode: error.providerErrorCode, - providerUnavailable: isProviderOutageCode(error.providerErrorCode), - }) + return failure(error.message, 'validation') } throw error } @@ -540,6 +538,18 @@ export async function createCredentialRecord( return { success: true, credential: created, created: true, auditMetadata: extraAuditMetadata } } catch (error: unknown) { + if (error instanceof OciCredentialVerificationError) { + const providerUnavailable = error.code !== 'invalid_credentials' + const providerErrorCode = providerUnavailable ? 'provider_unavailable' : 'invalid_credentials' + logger.warn(`OCI credential rejected: ${error.code}`) + return failure( + providerUnavailable + ? 'OCI is temporarily unavailable for credential verification' + : 'OCI rejected the API-key credential', + 'validation', + { providerErrorCode, providerUnavailable } + ) + } if (error instanceof AtlassianValidationError) { logger.warn(`Atlassian credential rejected: ${error.code}`, { code: error.code, diff --git a/apps/sim/lib/credentials/orchestration/index.test.ts b/apps/sim/lib/credentials/orchestration/index.test.ts index 9c8bb276535..fc898f885f1 100644 --- a/apps/sim/lib/credentials/orchestration/index.test.ts +++ b/apps/sim/lib/credentials/orchestration/index.test.ts @@ -67,6 +67,13 @@ vi.mock('@/lib/credentials/environment', () => ({ vi.mock('@/lib/credentials/atlassian-service-account', () => ({ AtlassianValidationError: class AtlassianValidationError extends Error {}, })) +vi.mock('@/lib/credentials/oci-api-key-service-account.server', () => ({ + OciCredentialVerificationError: class OciCredentialVerificationError extends Error { + constructor(readonly code: string) { + super(code) + } + }, +})) vi.mock('@/lib/credentials/token-service-accounts/errors', () => ({ TokenServiceAccountValidationError: class TokenServiceAccountValidationError extends Error {}, })) @@ -176,6 +183,13 @@ describe('performUpdateCredential — service-account secret rotation', () => { principal: { kind: 'user', id: 'ocid1.user.oc1..replacement' }, }) + const { ServiceAccountSecretError } = await import('@/lib/credentials/service-account-secret') + mockVerifyAndBuildServiceAccountSecret.mockRejectedValueOnce( + new ServiceAccountSecretError( + 'tenancyOcid, userOcid, fingerprint, privateKey, and region are required for OCI API-key credentials' + ) + ) + const incomplete = await performUpdateCredential({ credentialId: 'cred-1', userId: 'user-1', @@ -184,9 +198,12 @@ describe('performUpdateCredential — service-account secret rotation', () => { expect(incomplete).toMatchObject({ success: false, errorCode: 'validation', - error: expect.stringContaining('complete signing tuple'), + error: expect.stringContaining('tenancyOcid, userOcid, fingerprint, privateKey, and region'), }) - expect(mockVerifyAndBuildServiceAccountSecret).not.toHaveBeenCalled() + expect(mockVerifyAndBuildServiceAccountSecret).toHaveBeenCalledWith( + 'oci-api-key-service-account', + expect.objectContaining({ privateKey: 'replacement-key' }) + ) const complete = await performUpdateCredential({ credentialId: 'cred-1', @@ -198,7 +215,7 @@ describe('performUpdateCredential — service-account secret rotation', () => { region: 'us-ashburn-1', }) expect(complete.success).toBe(true) - expect(mockVerifyAndBuildServiceAccountSecret).toHaveBeenCalledWith( + expect(mockVerifyAndBuildServiceAccountSecret).toHaveBeenLastCalledWith( 'oci-api-key-service-account', expect.objectContaining({ tenancyOcid: 'ocid1.tenancy.oc1..tenant', @@ -212,6 +229,49 @@ describe('performUpdateCredential — service-account secret rotation', () => { expect(updatePayload().encryptedServiceAccountKey).toBe('new-oci-cipher') }) + it.each([ + ['invalid_credentials', 'invalid_credentials', 'OCI rejected the API-key credential'], + [ + 'service_unavailable', + 'provider_unavailable', + 'OCI is temporarily unavailable for credential verification', + ], + [ + 'invalid_response', + 'provider_unavailable', + 'OCI is temporarily unavailable for credential verification', + ], + ] as const)( + 'maps OCI %s rotation failures without changing generic service-account errors', + async (code, providerErrorCode, message) => { + mockCredential({ providerId: 'oci-api-key-service-account' }) + const { OciCredentialVerificationError } = await import( + '@/lib/credentials/oci-api-key-service-account.server' + ) + mockVerifyAndBuildServiceAccountSecret.mockRejectedValue( + new OciCredentialVerificationError(code) + ) + + const result = await performUpdateCredential({ + credentialId: 'cred-1', + userId: 'user-1', + tenancyOcid: 'ocid1.tenancy.oc1..tenant', + userOcid: 'ocid1.user.oc1..replacement', + fingerprint: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff', + privateKey: 'replacement-key', + region: 'us-ashburn-1', + }) + + expect(result).toMatchObject({ + success: false, + errorCode: 'validation', + error: message, + providerErrorCode, + }) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + } + ) + it('keeps a label the user typed instead of the derived identity', async () => { mockCredential({ displayName: 'Prod billing exporter' }) mockStoredBlob({ type: 'service_account', client_email: OLD_EMAIL }) @@ -481,6 +541,7 @@ describe('performUpdateCredential — service-account secret rotation', () => { }) expect(result).toMatchObject({ success: false, errorCode: 'validation' }) + expect(result).not.toHaveProperty('providerErrorCode') expect(dbChainMockFns.update).not.toHaveBeenCalled() expect(mockRecordAudit).not.toHaveBeenCalled() }) @@ -662,6 +723,54 @@ describe('createServiceAccountCredential', () => { resetDbChainMock() }) + it.each([ + ['invalid_credentials', 'invalid_credentials', false, 'OCI rejected the API-key credential'], + [ + 'service_unavailable', + 'provider_unavailable', + true, + 'OCI is temporarily unavailable for credential verification', + ], + [ + 'invalid_response', + 'provider_unavailable', + true, + 'OCI is temporarily unavailable for credential verification', + ], + ] as const)( + 'maps OCI %s creation failures through the existing provider result contract', + async (code, providerErrorCode, providerUnavailable, message) => { + const { OciCredentialVerificationError } = await import( + '@/lib/credentials/oci-api-key-service-account.server' + ) + mockVerifyAndBuildServiceAccountSecret.mockRejectedValue( + new OciCredentialVerificationError(code) + ) + + const result = await createServiceAccountCredential({ + workspaceId: 'workspace-1', + userId: 'user-1', + providerId: 'oci-api-key-service-account', + displayName: 'OCI signer', + tenancyOcid: 'ocid1.tenancy.oc1..tenant', + userOcid: 'ocid1.user.oc1..principal', + fingerprint: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff', + privateKey: 'provider-secret-key', + region: 'us-ashburn-1', + }) + + expect(result).toMatchObject({ + success: false, + errorCode: 'validation', + error: message, + providerErrorCode, + providerUnavailable, + }) + expect(JSON.stringify(result)).not.toContain('provider-secret-key') + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + } + ) + it('rejects an existing service-account source instead of discarding the submitted secret', async () => { mockVerifyAndBuildServiceAccountSecret.mockResolvedValue({ providerId: 'zoom-service-account', diff --git a/apps/sim/lib/credentials/orchestration/index.ts b/apps/sim/lib/credentials/orchestration/index.ts index 199ac826a01..ad778a79e97 100644 --- a/apps/sim/lib/credentials/orchestration/index.ts +++ b/apps/sim/lib/credentials/orchestration/index.ts @@ -35,6 +35,7 @@ import { deletePersonalEnvCredentialForUser, deleteWorkspaceEnvCredentials, } from '@/lib/credentials/environment' +import { OciCredentialVerificationError } from '@/lib/credentials/oci-api-key-service-account.server' import type { ServiceAccountFieldId } from '@/lib/credentials/service-account-fields' import { ServiceAccountSecretError, @@ -44,7 +45,6 @@ import { TokenServiceAccountValidationError } from '@/lib/credentials/token-serv import { invalidateEffectiveDecryptedEnvCache } from '@/lib/environment/utils' import { GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID, - OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID, SLACK_CUSTOM_BOT_PROVIDER_ID, SLACK_CUSTOM_BOT_SECRET_TYPE, } from '@/lib/oauth/types' @@ -296,24 +296,6 @@ export async function updateCredentialRecord( if (hasRotationSecret) { const providerId = params.credential.providerId ?? '' - if (providerId === OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID) { - const requiredOciFields = [ - 'tenancyOcid', - 'userOcid', - 'fingerprint', - 'privateKey', - 'region', - ] as const - const missingOciFields = requiredOciFields.filter((field) => params[field] === undefined) - if (missingOciFields.length > 0) { - return { - success: false, - error: `OCI credential rotation requires the complete signing tuple; missing ${missingOciFields.join(', ')}`, - errorCode: 'validation', - } - } - } - // A reconnect rebuilds the secret blob from the submitted fields only, and // the modal never prefills (secrets are never echoed back). For an actual // secret that is correct - the admin retypes it. But a non-secret selector @@ -422,11 +404,17 @@ export async function updateCredentialRecord( } } catch (error) { if (error instanceof ServiceAccountSecretError) { + return { success: false, error: error.message, errorCode: 'validation' } + } + if (error instanceof OciCredentialVerificationError) { + const providerUnavailable = error.code !== 'invalid_credentials' return { success: false, - error: error.message, + error: providerUnavailable + ? 'OCI is temporarily unavailable for credential verification' + : 'OCI rejected the API-key credential', errorCode: 'validation', - providerErrorCode: error.providerErrorCode, + providerErrorCode: providerUnavailable ? 'provider_unavailable' : 'invalid_credentials', } } if (error instanceof AtlassianValidationError) { diff --git a/apps/sim/lib/credentials/service-account-secret.test.ts b/apps/sim/lib/credentials/service-account-secret.test.ts index 9ee96d1a570..851eec752ad 100644 --- a/apps/sim/lib/credentials/service-account-secret.test.ts +++ b/apps/sim/lib/credentials/service-account-secret.test.ts @@ -218,7 +218,7 @@ describe('verifyAndBuildServiceAccountSecret', () => { }) it.each(['service_unavailable', 'invalid_response'] as const)( - 'classifies OCI %s failures as provider outages without exposing provider details', + 'preserves the dedicated OCI %s classification for orchestration', async (code) => { const { OciCredentialVerificationError } = await import( '@/lib/credentials/oci-api-key-service-account.server' @@ -233,16 +233,16 @@ describe('verifyAndBuildServiceAccountSecret', () => { region: 'us-ashburn-1', }).catch((error: unknown) => error) - expect(failure).toBeInstanceOf(ServiceAccountSecretError) + expect(failure).toBeInstanceOf(OciCredentialVerificationError) expect(failure).toMatchObject({ - message: 'OCI is temporarily unavailable for credential verification', - providerErrorCode: 'provider_unavailable', + message: code, + code, }) expect(JSON.stringify(failure)).not.toContain('provider-secret-key') } ) - it('classifies local OCI field validation as rejected credentials', async () => { + it('preserves the dedicated OCI invalid-credential classification for orchestration', async () => { const { OciCredentialVerificationError } = await import( '@/lib/credentials/oci-api-key-service-account.server' ) @@ -258,11 +258,7 @@ describe('verifyAndBuildServiceAccountSecret', () => { privateKey: 'invalid-key', region: 'us-ashburn-1', }) - ).rejects.toMatchObject({ - name: 'ServiceAccountSecretError', - message: 'OCI rejected the API-key credential', - providerErrorCode: 'invalid_credentials', - }) + ).rejects.toMatchObject({ message: 'invalid_credentials', code: 'invalid_credentials' }) }) it('does not misclassify an internal OCI credential failure as rejected credentials', async () => { diff --git a/apps/sim/lib/credentials/service-account-secret.ts b/apps/sim/lib/credentials/service-account-secret.ts index 0cfb44fd29f..33dceba1d73 100644 --- a/apps/sim/lib/credentials/service-account-secret.ts +++ b/apps/sim/lib/credentials/service-account-secret.ts @@ -20,10 +20,7 @@ import { getClientCredentialAccountMinter, } from '@/lib/credentials/client-credential-accounts/server' import { slackCustomBotDisplayName } from '@/lib/credentials/display-name' -import { - OciCredentialVerificationError, - verifyAndEncryptOciApiKeyCredential, -} from '@/lib/credentials/oci-api-key-service-account.server' +import { verifyAndEncryptOciApiKeyCredential } from '@/lib/credentials/oci-api-key-service-account.server' import { type ServiceAccountPrincipal, serviceAccountPrincipalMetadata, @@ -87,10 +84,7 @@ export interface ServiceAccountSecretResult { /** Thrown when a service-account secret is missing or fails provider verification. */ export class ServiceAccountSecretError extends Error { - constructor( - message: string, - readonly providerErrorCode?: string - ) { + constructor(message: string) { super(message) this.name = 'ServiceAccountSecretError' } @@ -239,36 +233,22 @@ async function buildOciApiKeyServiceAccountSecret( 'tenancyOcid, userOcid, fingerprint, privateKey, and region are required for OCI API-key credentials' ) } - try { - const result = await verifyAndEncryptOciApiKeyCredential({ - tenancyOcid, - userOcid, - fingerprint, - privateKey, - ...(privateKeyPassphrase !== undefined ? { privateKeyPassphrase } : {}), - region, - }) - const principal: ServiceAccountPrincipal = { kind: 'user', id: result.userOcid } - const metadata = serviceAccountPrincipalMetadata(principal) - return { - providerId: OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID, - encryptedServiceAccountKey: result.encryptedServiceAccountKey, - displayName: result.userOcid, - auditMetadata: metadata, - principal, - } - } catch (error) { - if (error instanceof OciCredentialVerificationError) { - const providerUnavailable = - error.code === 'service_unavailable' || error.code === 'invalid_response' - throw new ServiceAccountSecretError( - providerUnavailable - ? 'OCI is temporarily unavailable for credential verification' - : 'OCI rejected the API-key credential', - providerUnavailable ? 'provider_unavailable' : 'invalid_credentials' - ) - } - throw error + const result = await verifyAndEncryptOciApiKeyCredential({ + tenancyOcid, + userOcid, + fingerprint, + privateKey, + ...(privateKeyPassphrase !== undefined ? { privateKeyPassphrase } : {}), + region, + }) + const principal: ServiceAccountPrincipal = { kind: 'user', id: result.userOcid } + const metadata = serviceAccountPrincipalMetadata(principal) + return { + providerId: OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID, + encryptedServiceAccountKey: result.encryptedServiceAccountKey, + displayName: result.userOcid, + auditMetadata: metadata, + principal, } } diff --git a/apps/sim/lib/oauth/token-resolution.test.ts b/apps/sim/lib/oauth/token-resolution.test.ts index 75195878f20..799c21748c8 100644 --- a/apps/sim/lib/oauth/token-resolution.test.ts +++ b/apps/sim/lib/oauth/token-resolution.test.ts @@ -8,7 +8,6 @@ const { mockCaptureServerEvent, mockExecuteManagedToken, mockGetCredential, - mockGetServiceConfigByServiceId, mockGetToolMetadata, mockRecordAudit, mockRefreshTokenIfNeeded, @@ -19,7 +18,6 @@ const { mockCaptureServerEvent: vi.fn(), mockExecuteManagedToken: vi.fn(), mockGetCredential: vi.fn(), - mockGetServiceConfigByServiceId: vi.fn(), mockGetToolMetadata: vi.fn(), mockRecordAudit: vi.fn(), mockRefreshTokenIfNeeded: vi.fn(), @@ -81,7 +79,6 @@ vi.mock('@/tools/metadata', () => ({ vi.mock('@/lib/oauth/utils', () => ({ getCanonicalScopesForProvider: vi.fn().mockReturnValue([]), - getServiceConfigByServiceId: mockGetServiceConfigByServiceId, })) import { OrchestrationError } from '@/lib/core/orchestration/types' @@ -382,7 +379,6 @@ describe('resolveCredentialAccessToken', () => { beforeEach(() => { vi.clearAllMocks() mockResolveOAuthAccountId.mockResolvedValue(null) - mockGetServiceConfigByServiceId.mockReturnValue(null) authenticate.mockResolvedValue(INTERNAL_AUTH) resolveManagedPrincipal.mockResolvedValue(EXECUTOR_PRINCIPAL) mockGetToolMetadata.mockReturnValue({ @@ -448,171 +444,6 @@ describe('resolveCredentialAccessToken', () => { }) }) - it('hands an authorized OCI credential to the resolver by authoritative ID only', async () => { - const supplied = { - credentialType: 'service_account', - credentialId: 'caller-controlled-alias', - providerId: 'google-service-account', - workspaceId: 'ws-1', - accountId: '', - usedCredentialTable: true, - } as const - const authoritative = { - ...supplied, - credentialId: 'credential-authoritative', - providerId: 'oci-api-key-service-account', - } as const - mockResolveOAuthAccountId.mockResolvedValueOnce(supplied).mockResolvedValueOnce(authoritative) - mockGetToolMetadata.mockReturnValue({ - oauth: { - required: true, - provider: 'oci', - credentialKind: 'service-account', - }, - }) - mockGetServiceConfigByServiceId.mockReturnValue({ - serviceAccountProviderId: 'oci-api-key-service-account', - }) - mockAuthorizeCredentialUseForAuth.mockResolvedValue({ - ok: true, - requesterUserId: 'user-1', - workspaceId: 'ws-1', - resolvedCredentialId: 'credential-authoritative', - }) - mockResolveServiceAccountToken.mockResolvedValue({ accessToken: 'credential-authoritative' }) - - await expect( - resolveCredentialAccessToken({ - requestId: 'req-oci', - credentialId: 'caller-controlled-alias', - toolId: 'future_oci_tool', - authenticate, - }) - ).resolves.toEqual({ - ok: true, - token: expect.objectContaining({ accessToken: 'credential-authoritative' }), - }) - expect(mockResolveServiceAccountToken).toHaveBeenCalledWith( - 'credential-authoritative', - 'oci-api-key-service-account', - [], - undefined - ) - }) - - it('rejects OCI credentials when trusted tool metadata is not provider-bound', async () => { - mockResolveOAuthAccountId.mockResolvedValue({ - credentialType: 'service_account', - credentialId: 'credential-authoritative', - providerId: 'oci-api-key-service-account', - workspaceId: 'ws-1', - accountId: '', - usedCredentialTable: true, - }) - mockGetToolMetadata.mockReturnValue({ - oauth: { required: true, provider: 'oci', credentialKind: 'service-account' }, - }) - mockGetServiceConfigByServiceId.mockReturnValue({ - serviceAccountProviderId: 'different-provider', - }) - mockAuthorizeCredentialUseForAuth.mockResolvedValue({ - ok: true, - requesterUserId: 'user-1', - workspaceId: 'ws-1', - resolvedCredentialId: 'credential-authoritative', - }) - - await expect( - resolveCredentialAccessToken({ - requestId: 'req-oci', - credentialId: 'credential-authoritative', - toolId: 'future_oci_tool', - authenticate, - }) - ).resolves.toMatchObject({ - ok: false, - status: 500, - code: 'OCI_CREDENTIAL_TOOL_UNSUPPORTED', - }) - expect(authenticate).toHaveBeenCalledTimes(1) - expect(mockAuthorizeCredentialUseForAuth).toHaveBeenCalledTimes(1) - expect(mockResolveOAuthAccountId).toHaveBeenCalledTimes(2) - expect(mockResolveServiceAccountToken).not.toHaveBeenCalled() - }) - - it('does not reveal unsupported OCI tool metadata before authorization', async () => { - mockResolveOAuthAccountId.mockResolvedValue({ - credentialType: 'service_account', - credentialId: 'credential-authoritative', - providerId: 'oci-api-key-service-account', - workspaceId: 'ws-1', - accountId: '', - usedCredentialTable: true, - }) - mockGetToolMetadata.mockReturnValue({ - oauth: { required: true, provider: 'oci', credentialKind: 'service-account' }, - }) - mockGetServiceConfigByServiceId.mockReturnValue({ - serviceAccountProviderId: 'different-provider', - }) - mockAuthorizeCredentialUseForAuth.mockResolvedValue({ - ok: false, - error: 'You do not have access to this credential.', - }) - - await expect( - resolveCredentialAccessToken({ - requestId: 'req-oci', - credentialId: 'credential-authoritative', - toolId: 'future_oci_tool', - authenticate, - }) - ).resolves.toEqual({ - ok: false, - status: 403, - error: 'You do not have access to this credential.', - }) - expect(authenticate).toHaveBeenCalledTimes(1) - expect(mockResolveOAuthAccountId).toHaveBeenCalledTimes(1) - expect(mockResolveServiceAccountToken).not.toHaveBeenCalled() - }) - - it('rejects an OAuth credential selected for an OCI service-account tool', async () => { - const oauthCredential = { - accountId: 'oauth-account', - workspaceId: 'ws-1', - usedCredentialTable: true, - } as const - mockResolveOAuthAccountId.mockResolvedValue(oauthCredential) - mockGetToolMetadata.mockReturnValue({ - oauth: { required: true, provider: 'oci', credentialKind: 'service-account' }, - }) - mockGetServiceConfigByServiceId.mockReturnValue({ - serviceAccountProviderId: 'oci-api-key-service-account', - }) - mockAuthorizeCredentialUseForAuth.mockResolvedValue({ - ok: true, - requesterUserId: 'user-1', - credentialOwnerUserId: 'owner-1', - workspaceId: 'ws-1', - resolvedCredentialId: 'oauth-credential', - }) - - await expect( - resolveCredentialAccessToken({ - requestId: 'req-oci', - credentialId: 'oauth-credential', - toolId: 'future_oci_tool', - authenticate, - }) - ).resolves.toEqual({ ok: false, status: 403, error: 'Unauthorized' }) - expect(authenticate).toHaveBeenCalledTimes(1) - expect(mockResolveOAuthAccountId).toHaveBeenCalledTimes(2) - expect(mockGetCredential).not.toHaveBeenCalled() - expect(mockRefreshTokenIfNeeded).not.toHaveBeenCalled() - expect(mockResolveServiceAccountToken).not.toHaveBeenCalled() - }) - it('rejects a managed credential when no delegation resolver is wired', async () => { mockResolveOAuthAccountId.mockResolvedValue(MANAGED_RESOLVED) diff --git a/apps/sim/lib/oauth/token-resolution.ts b/apps/sim/lib/oauth/token-resolution.ts index c822b06f132..f198d900626 100644 --- a/apps/sim/lib/oauth/token-resolution.ts +++ b/apps/sim/lib/oauth/token-resolution.ts @@ -26,8 +26,7 @@ import { } from '@/lib/oauth/microsoft-dataverse' import { parseQuickBooksAccountId } from '@/lib/oauth/quickbooks' import { extractSalesforceInstanceUrl, isSalesforceOAuthProviderId } from '@/lib/oauth/salesforce' -import { type OAuthService, OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types' -import { getCanonicalScopesForProvider, getServiceConfigByServiceId } from '@/lib/oauth/utils' +import { getCanonicalScopesForProvider } from '@/lib/oauth/utils' import { captureServerEvent } from '@/lib/posthog/server' import { getToolMetadata } from '@/tools/metadata' import { extractZohoDeskBaseFromScope } from '@/tools/zoho_desk/host-allowlist' @@ -62,10 +61,6 @@ export interface ResolveCredentialTokenInput { auditRequest?: CredentialAuditRequest /** Credential lookup already performed by {@link resolveCredentialAccessToken}'s dispatch. */ resolvedCredential: ResolvedCredential | null - /** Trusted OCI provider binding derived from registered tool metadata. */ - expectedServiceAccountProviderId?: typeof OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID - /** Rejects an OCI credential after authorization when the tool has no trusted OCI binding. */ - rejectUnexpectedOciServiceAccount?: boolean } export type ResolveCredentialTokenResult = @@ -255,62 +250,25 @@ export async function resolveCredentialToken( return { ok: false, status: 400, error: 'impersonateEmail must be a valid email address' } } - let resolved = input.resolvedCredential + const resolved = input.resolvedCredential const authz = await authorizeCredentialUseForAuth(auth, { credentialId, workflowId, callerUserId, }) - const expectsOciServiceAccount = - input.expectedServiceAccountProviderId === OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID - if (expectsOciServiceAccount || input.rejectUnexpectedOciServiceAccount) { - if (!authz.ok) { - return { ok: false, status: 403, error: authz.error || 'Unauthorized' } - } - - const authoritativeId = authz.resolvedCredentialId - if (!authoritativeId) return { ok: false, status: 403, error: 'Unauthorized' } - - const authoritative = await resolveOAuthAccountId(authoritativeId) - const isAuthoritativeOciServiceAccount = - authoritative?.credentialType === 'service_account' && - authoritative.credentialId === authoritativeId && - authoritative.providerId === OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID - - if (expectsOciServiceAccount && !isAuthoritativeOciServiceAccount) { - return { ok: false, status: 403, error: 'Unauthorized' } - } - - if (input.rejectUnexpectedOciServiceAccount && isAuthoritativeOciServiceAccount) { - logger.error(`[${requestId}] Tool is not configured for OCI API-key credentials`) - return { - ok: false, - status: 500, - code: 'OCI_CREDENTIAL_TOOL_UNSUPPORTED', - error: 'This tool is not configured to use OCI API-key credentials', - } - } - - resolved = authoritative - } - if (resolved?.credentialType === 'service_account' && resolved.credentialId) { if (!authz.ok) { return { ok: false, status: 403, error: authz.error || 'Unauthorized' } } - const serviceAccountCredentialId = resolved.credentialId - const serviceAccountProviderId = resolved.providerId const saActorId = authz.requesterUserId - const saWorkspaceId = expectsOciServiceAccount - ? (authz.workspaceId ?? null) - : (resolved.workspaceId ?? authz.workspaceId ?? null) + const saWorkspaceId = resolved.workspaceId ?? authz.workspaceId ?? null try { const result = await resolveServiceAccountToken( - serviceAccountCredentialId, - serviceAccountProviderId, + resolved.credentialId, + resolved.providerId, scopes ?? [], impersonateEmail ) @@ -319,8 +277,8 @@ export async function resolveCredentialToken( recordCredentialAccess({ actorId: saActorId, workspaceId: saWorkspaceId, - resourceId: serviceAccountCredentialId, - providerId: serviceAccountProviderId, + resourceId: resolved.credentialId, + providerId: resolved.providerId, credentialType: 'service_account', auditRequest, }) @@ -438,21 +396,6 @@ export async function resolveCredentialAccessToken( const resolved = credentialId ? await resolveOAuthAccountId(credentialId) : null if (resolved?.credentialType !== 'managed_oauth' || !resolved.credentialId) { - const toolMetadata = toolId ? getToolMetadata(toolId) : undefined - const serviceId = toolMetadata?.oauth?.provider as OAuthService | undefined - const service = serviceId ? getServiceConfigByServiceId(serviceId) : null - const isOciServiceAccountTool = - toolMetadata?.oauth?.required === true && - toolMetadata.oauth.credentialKind === 'service-account' && - service?.serviceAccountProviderId === OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID - const expectedServiceAccountProviderId = isOciServiceAccountTool - ? OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID - : undefined - const rejectUnexpectedOciServiceAccount = - resolved?.credentialType === 'service_account' && - resolved.providerId === OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID && - !isOciServiceAccountTool - const auth = await input.authenticate() return resolveCredentialToken(auth, { requestId, @@ -468,8 +411,6 @@ export async function resolveCredentialAccessToken( callerUserId: input.callerUserId, auditRequest, resolvedCredential: resolved, - expectedServiceAccountProviderId, - rejectUnexpectedOciServiceAccount, }) } From 5055d3d39ed123dc4c08820789d2dc7c3104436a Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Fri, 4 Sep 2026 12:28:54 -0700 Subject: [PATCH 20/29] fix(oci): tighten request lifecycle --- .../lib/internal/oci/client.server.test.ts | 64 ++++++++++++++++++- apps/sim/lib/internal/oci/client.server.ts | 62 ++++++++++++------ apps/sim/lib/internal/oci/endpoints.ts | 6 +- 3 files changed, 109 insertions(+), 23 deletions(-) diff --git a/apps/sim/lib/internal/oci/client.server.test.ts b/apps/sim/lib/internal/oci/client.server.test.ts index 0cd83de9fc7..eed5285383b 100644 --- a/apps/sim/lib/internal/oci/client.server.test.ts +++ b/apps/sim/lib/internal/oci/client.server.test.ts @@ -201,7 +201,15 @@ describe('credential-bound OCI client', () => { }) it('loads only an exact credential/workspace/type/provider row before decryption', async () => { - await createPreparedClient() + const { client, endpoint } = await createPreparedClient() + + await client.request({ + endpoint, + method: 'GET', + encodedPath: '/v1/test', + timeoutMs: 10_000, + maxResponseBytes: 1024, + }) expect(mocks.predicates).toEqual([ { field: 'credential.id', value: 'credential-authoritative' }, @@ -490,6 +498,28 @@ describe('credential-bound OCI client', () => { expect(mocks.secureFetch).not.toHaveBeenCalled() }) + it.each(['DELETE', 'POST', 'PUT', 'PATCH'] as const)( + 'rejects caller-asserted safe retries for %s before signing or transport', + async (method) => { + const { client, endpoint } = await createPreparedClient() + const bodyFields = + method === 'DELETE' ? {} : { body: new Uint8Array(), contentType: 'text/plain' } + + await expect( + client.request({ + endpoint, + method, + encodedPath: '/v1/test', + ...bodyFields, + retry: { kind: 'safe', maxAttempts: 2 }, + timeoutMs: 10_000, + maxResponseBytes: 1024, + } as unknown as OciRequest) + ).rejects.toMatchObject({ code: 'invalid_request' }) + expect(mocks.secureFetch).not.toHaveBeenCalled() + } + ) + it('does not retry unless the operation opts in', async () => { mocks.secureFetch.mockResolvedValue( secureResponse({ status: 503, body: '{"message":"secret"}' }) @@ -508,12 +538,15 @@ describe('credential-bound OCI client', () => { }) it('re-signs every retry while preserving exact bytes and retry token', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-09-03T19:00:00.000Z')) + mocks.backoff.mockReturnValue(1000) mocks.secureFetch .mockResolvedValueOnce(secureResponse({ status: 503, body: '{"code":"Busy"}' })) .mockResolvedValueOnce(secureResponse({ status: 200, body: 'ok' })) const { client, endpoint } = await createPreparedClient() const body = new Uint8Array([9, 8, 7]) - await client.request({ + const pending = client.request({ endpoint, method: 'PUT', encodedPath: '/v1/test', @@ -523,6 +556,8 @@ describe('credential-bound OCI client', () => { timeoutMs: 10_000, maxResponseBytes: 1024, }) + await vi.advanceTimersByTimeAsync(1000) + await pending const first = mocks.secureFetch.mock.calls[0][2] const second = mocks.secureFetch.mock.calls[1][2] @@ -533,6 +568,31 @@ describe('credential-bound OCI client', () => { expect(first.headers.authorization).not.toBe(second.headers.authorization) }) + it('never manufactures future signing dates under rapid request volume', async () => { + vi.useFakeTimers() + const now = new Date('2026-09-03T19:00:00.000Z') + vi.setSystemTime(now) + mocks.secureFetch.mockImplementation(async () => secureResponse({})) + const { client, endpoint } = await createPreparedClient() + + await Promise.all( + Array.from({ length: 305 }, () => + client.request({ + endpoint, + method: 'GET', + encodedPath: '/v1/test', + timeoutMs: 10_000, + maxResponseBytes: 1024, + }) + ) + ) + + const signingDates = mocks.secureFetch.mock.calls.map( + (call) => (call[2] as { headers: Record }).headers['x-date'] + ) + expect(new Set(signingDates)).toEqual(new Set([now.toUTCString()])) + }) + it('retries only the exact internal IncorrectState 409 classification', async () => { mocks.secureFetch .mockResolvedValueOnce(secureResponse({ status: 409, body: '{"code":"IncorrectState"}' })) diff --git a/apps/sim/lib/internal/oci/client.server.ts b/apps/sim/lib/internal/oci/client.server.ts index 2e9fba7a889..5eb21387e9f 100644 --- a/apps/sim/lib/internal/oci/client.server.ts +++ b/apps/sim/lib/internal/oci/client.server.ts @@ -47,25 +47,50 @@ import { getServiceConfigByServiceId } from '@/lib/oauth/utils' export type OciRequestMethod = 'GET' | 'HEAD' | 'DELETE' | 'POST' | 'PUT' | 'PATCH' -export type OciRetryPolicy = - | { readonly kind: 'safe'; readonly maxAttempts: number } - | { readonly kind: 'tokenized'; readonly maxAttempts: number; readonly retryToken: string } +export interface OciSafeRetryPolicy { + readonly kind: 'safe' + readonly maxAttempts: number +} + +export interface OciTokenizedRetryPolicy { + readonly kind: 'tokenized' + readonly maxAttempts: number + readonly retryToken: string +} -export interface OciRequest { +export type OciRetryPolicy = OciSafeRetryPolicy | OciTokenizedRetryPolicy + +interface OciRequestBase { readonly endpoint: OciPreparedEndpoint - readonly method: OciRequestMethod readonly encodedPath: string readonly queryPairs?: readonly (readonly [string, string])[] readonly headers?: Readonly> - readonly body?: Uint8Array - readonly contentType?: string readonly timeoutMs: number readonly maxResponseBytes: number readonly responseHeaders?: readonly string[] - readonly retry?: OciRetryPolicy readonly signal?: AbortSignal } +export type OciRequest = + | (OciRequestBase & { + readonly method: 'GET' | 'HEAD' + readonly body?: never + readonly contentType?: never + readonly retry?: OciRetryPolicy + }) + | (OciRequestBase & { + readonly method: 'DELETE' + readonly body?: never + readonly contentType?: never + readonly retry?: OciTokenizedRetryPolicy + }) + | (OciRequestBase & { + readonly method: 'POST' | 'PUT' | 'PATCH' + readonly body: Uint8Array + readonly contentType: string + readonly retry?: OciTokenizedRetryPolicy + }) + declare const authenticatedOciResponseBrand: unique symbol export interface OciAuthenticatedResponse { @@ -121,6 +146,7 @@ interface SignedOciRequest { } const BODY_METHODS: ReadonlySet = new Set(['POST', 'PUT', 'PATCH']) +const SAFE_RETRY_METHODS: ReadonlySet = new Set(['GET', 'HEAD']) const REQUEST_METHODS: ReadonlySet = new Set([ 'GET', 'HEAD', @@ -470,6 +496,7 @@ function validateRequest(request: OciRequest): { typeof request.retry !== 'object' || Array.isArray(request.retry) || (request.retry.kind !== 'safe' && request.retry.kind !== 'tokenized') || + (request.retry.kind === 'safe' && !SAFE_RETRY_METHODS.has(request.method)) || Object.keys(request.retry).some( (key) => key !== 'kind' && @@ -715,17 +742,19 @@ export async function createOciClient(params: CreateOciClientParams): Promise | undefined - let lastSigningTime = 0 + let credentialMaterial: OciCredentialMaterial | undefined const preparedEndpoints = new WeakSet() const endpointPolicies = new WeakMap() const responseSnapshots = new WeakMap() - const getMaterial = () => { + const getMaterial = async () => { materialPromise ??= loadCredentialMaterial({ credentialId: params.credentialId, workspaceId: params.workspaceId, }) - return materialPromise + const material = await materialPromise + credentialMaterial = material + return material } const assertPolicyOwner = (policy: OciEndpointPolicy) => { if (policy.serviceId !== params.serviceId) throw new OciClientError('invalid_endpoint') @@ -734,12 +763,6 @@ export async function createOciClient(params: CreateOciClientParams): Promise { - const now = Math.max(Date.now(), lastSigningTime + 1000) - lastSigningTime = now - return new Date(now) - } - const client: OciClient = { async prepareStaticEndpoint(policy) { assertPolicyOwner(policy) @@ -781,11 +804,12 @@ export async function createOciClient(params: CreateOciClientParams): Promise Date: Fri, 4 Sep 2026 12:44:42 -0700 Subject: [PATCH 21/29] fix(oci): preserve transport size errors --- .../lib/internal/oci/client.server.test.ts | 20 +++++++++++++++++++ apps/sim/lib/internal/oci/client.server.ts | 3 +++ 2 files changed, 23 insertions(+) diff --git a/apps/sim/lib/internal/oci/client.server.test.ts b/apps/sim/lib/internal/oci/client.server.test.ts index eed5285383b..27ffb1f1af2 100644 --- a/apps/sim/lib/internal/oci/client.server.test.ts +++ b/apps/sim/lib/internal/oci/client.server.test.ts @@ -3,6 +3,7 @@ */ import { createPublicKey, verify } from 'node:crypto' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' const mocks = vi.hoisted(() => ({ backoff: vi.fn(), @@ -753,6 +754,25 @@ describe('credential-bound OCI client', () => { expect(cancel).toHaveBeenCalled() }) + it('preserves response-too-large when transport rejects a declared content length', async () => { + mocks.secureFetch.mockRejectedValueOnce( + new PayloadSizeLimitError({ label: 'OCI response', maxBytes: 3, observedBytes: 4 }) + ) + const { client, endpoint } = await createPreparedClient() + + await expect( + client.request({ + endpoint, + method: 'GET', + encodedPath: '/v1/test', + retry: { kind: 'safe', maxAttempts: 2 }, + timeoutMs: 10_000, + maxResponseBytes: 3, + }) + ).rejects.toMatchObject({ code: 'response_too_large' }) + expect(mocks.secureFetch).toHaveBeenCalledOnce() + }) + it('rejects fabricated and cross-client authenticated discovery responses', async () => { const policy = createOciDiscoveredEndpointPolicy({ serviceId: OCI_SERVICE_ID, diff --git a/apps/sim/lib/internal/oci/client.server.ts b/apps/sim/lib/internal/oci/client.server.ts index 5eb21387e9f..6b8e01b408a 100644 --- a/apps/sim/lib/internal/oci/client.server.ts +++ b/apps/sim/lib/internal/oci/client.server.ts @@ -851,6 +851,9 @@ export async function createOciClient(params: CreateOciClientParams): Promise= deadline.deadlineAt - Date.now()) { From 2864a4df540dd6e2db5090acbd99e6cce9f28eb1 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Sat, 5 Sep 2026 12:10:10 -0700 Subject: [PATCH 22/29] feat(oci): support multi-label service prefixes --- apps/sim/lib/internal/oci/endpoints.test.ts | 138 +++++++++++++++++++- apps/sim/lib/internal/oci/endpoints.ts | 16 ++- 2 files changed, 151 insertions(+), 3 deletions(-) diff --git a/apps/sim/lib/internal/oci/endpoints.test.ts b/apps/sim/lib/internal/oci/endpoints.test.ts index 5f74df53478..b781863127e 100644 --- a/apps/sim/lib/internal/oci/endpoints.test.ts +++ b/apps/sim/lib/internal/oci/endpoints.test.ts @@ -93,6 +93,142 @@ describe('OCI endpoint policies', () => { }) }) + it.each(['secrets.vaults', 'ingestion.logging', 'identity', 'telemetry-ingestion', 'a', 'a0'])( + 'constructs exact regional hosts for the service prefix %s', + (serviceName) => { + const policy = createOciStaticEndpointPolicy({ + serviceId: OCI_SERVICE_ID, + serviceName, + hostnameTemplate: 'regional-oci', + }) + expect(Object.isFrozen(policy)).toBe(true) + expect(policy.serviceName).toBe(serviceName) + for (const regionId of ['us-ashburn-1', 'us-gov-ashburn-1']) { + const selectedRegion = getOciRegion(regionId) + expect(resolveStaticOciEndpoint(policy, selectedRegion).origin).toBe( + `https://${serviceName}.${regionId}.oci.${selectedRegion.realm.domain}` + ) + } + } + ) + + it('preserves multi-label ownership in authenticated discovery', () => { + const policy = createOciDiscoveredEndpointPolicy({ + serviceId: OCI_SERVICE_ID, + serviceName: 'secrets.vaults', + hostnameTemplate: 'regional-oci', + responsePolicy: staticPolicy, + source: { kind: 'json', path: ['endpoint'] }, + }) + expect(Object.isFrozen(policy)).toBe(true) + expect( + resolveDiscoveredOciEndpoint( + policy, + region, + 'https://resource.secrets.vaults.us-ashburn-1.oci.oraclecloud.com' + ).serviceName + ).toBe('secrets.vaults') + for (const origin of [ + 'https://resource.vaults.us-ashburn-1.oci.oraclecloud.com', + 'https://resource.secrets.vaults.eu-frankfurt-1.oci.oraclecloud.com', + 'https://resource.secrets.vaults.us-ashburn-1.oci.oraclegovcloud.com', + 'https://resource.secrets.vaults.us-ashburn-1.oci.oraclecloud.com.attacker.example', + ]) { + expect(() => resolveDiscoveredOciEndpoint(policy, region, origin)).toThrow() + } + }) + + it.each([ + '', + '.', + '.identity', + 'identity.', + 'secrets..vaults', + '-identity', + 'identity-', + '1identity', + 'Identity', + 'identity_service', + 'identity service', + 'identity\n', + 'identity\r', + 'identity\t', + 'identity\0', + 'identité', + 'https://identity', + 'identity:443', + 'identity/path', + 'identity\\path', + '*.identity', + 'identity?x=1', + 'identity#fragment', + 'identity@host', + 'a'.repeat(64), + null, + undefined, + 42, + ['identity'], + ])('rejects malformed service prefixes in both policy factories: %j', (serviceName) => { + expect(() => + createOciStaticEndpointPolicy({ + serviceId: OCI_SERVICE_ID, + serviceName: serviceName as never, + hostnameTemplate: 'regional', + }) + ).toThrow('service name') + expect(() => + createOciDiscoveredEndpointPolicy({ + serviceId: OCI_SERVICE_ID, + serviceName: serviceName as never, + hostnameTemplate: 'regional', + responsePolicy: staticPolicy, + source: { kind: 'json', path: ['endpoint'] }, + }) + ).toThrow('service name') + }) + + it('bounds labels, prefixes, and complete hostnames', () => { + const label = 'a'.repeat(63) + expect(regionalOciHostname(label, region, 'regional')).toBe( + `${label}.${region.id}.${region.realm.domain}` + ) + const prefix = [label, label, label, 'b'.repeat(61)].join('.') + expect(prefix.length).toBe(253) + const policy = createOciStaticEndpointPolicy({ + serviceId: OCI_SERVICE_ID, + serviceName: prefix, + hostnameTemplate: 'regional', + }) + expect(policy.serviceName).toBe(prefix) + expect(() => + createOciStaticEndpointPolicy({ + ...policy, + serviceName: `${prefix}b`, + }) + ).toThrow('service name') + expect(() => resolveStaticOciEndpoint(policy, region)).toThrow('hostname') + + const suffix = `.${region.id}.oci.${region.realm.domain}` + const boundedPrefix = [label, label, label, 'b'.repeat(253 - suffix.length - 192)].join('.') + const boundedPolicy = createOciStaticEndpointPolicy({ + serviceId: OCI_SERVICE_ID, + serviceName: boundedPrefix, + hostnameTemplate: 'regional-oci', + }) + expect(resolveStaticOciEndpoint(boundedPolicy, region).hostname.length).toBe(253) + expect(() => regionalOciHostname(`${boundedPrefix}b`, region, 'regional-oci')).toThrow( + 'hostname' + ) + const boundedDiscovery = createOciDiscoveredEndpointPolicy({ + ...boundedPolicy, + responsePolicy: staticPolicy, + source: { kind: 'json', path: ['endpoint'] }, + }) + expect(() => + resolveDiscoveredOciEndpoint(boundedDiscovery, region, `https://a.${boundedPrefix}${suffix}`) + ).toThrow() + }) + it.each([ 'http://resource.database.us-ashburn-1.oraclecloud.com', 'https://resource.database.us-ashburn-1.oraclecloud.com:8443', @@ -128,7 +264,7 @@ describe('OCI endpoint policies', () => { expect(() => createOciStaticEndpointPolicy({ serviceId: OCI_SERVICE_ID, - serviceName: 'bad.name', + serviceName: 'bad..name', hostnameTemplate: 'regional', }) ).toThrow('service name') diff --git a/apps/sim/lib/internal/oci/endpoints.ts b/apps/sim/lib/internal/oci/endpoints.ts index daab77ab541..9fe6f6881d3 100644 --- a/apps/sim/lib/internal/oci/endpoints.ts +++ b/apps/sim/lib/internal/oci/endpoints.ts @@ -176,6 +176,7 @@ const REGION_REALMS = { } as const satisfies Record export const OCI_REGION_IDS = Object.freeze(Object.keys(REGION_REALMS)) +const MAX_HOSTNAME_LENGTH = 253 function normalizeRegionId(regionId: string): string { return regionId.trim().toLowerCase() @@ -203,7 +204,13 @@ export function resolveEffectiveOciRegion(defaultRegion: string, override?: stri } function assertServiceName(value: string): void { - if (!/^[a-z][a-z0-9-]{0,62}$/.test(value)) { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > MAX_HOSTNAME_LENGTH || + /[^a-z0-9.-]/.test(value) || + value.split('.').some((label) => !/^[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label)) + ) { throw new Error('OCI endpoint policy service name is invalid') } } @@ -288,7 +295,11 @@ export function regionalOciHostname( assertServiceName(serviceName) assertHostnameTemplate(hostnameTemplate) const ociLabel = hostnameTemplate === 'regional-oci' ? '.oci' : '' - return `${serviceName}.${region.id}${ociLabel}.${region.realm.domain}` + const hostname = `${serviceName}.${region.id}${ociLabel}.${region.realm.domain}` + if (hostname.length > MAX_HOSTNAME_LENGTH) { + throw new Error('OCI endpoint policy hostname is invalid') + } + return hostname } function validateOciOrigin(params: { @@ -319,6 +330,7 @@ function validateOciOrigin(params: { url.pathname !== '/' || url.search !== '' || url.hash !== '' || + url.hostname.length > MAX_HOSTNAME_LENGTH || isIpLiteral(unwrapIpv6Brackets(url.hostname)) || url.origin !== params.origin ) { From 6b6d43fb47174faa8d0ebf689ac00833f37f369c Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Sat, 5 Sep 2026 12:11:40 -0700 Subject: [PATCH 23/29] feat(oci): support region-first endpoint policies --- .../lib/internal/oci/client.server.test.ts | 51 +++++++++++++ apps/sim/lib/internal/oci/endpoints.test.ts | 76 +++++++++++++++++++ apps/sim/lib/internal/oci/endpoints.ts | 9 ++- 3 files changed, 133 insertions(+), 3 deletions(-) diff --git a/apps/sim/lib/internal/oci/client.server.test.ts b/apps/sim/lib/internal/oci/client.server.test.ts index 27ffb1f1af2..a234601fd7d 100644 --- a/apps/sim/lib/internal/oci/client.server.test.ts +++ b/apps/sim/lib/internal/oci/client.server.test.ts @@ -773,6 +773,57 @@ describe('credential-bound OCI client', () => { expect(mocks.secureFetch).toHaveBeenCalledOnce() }) + it('invokes a Functions endpoint discovered through the same client and management policy', async () => { + const managementPolicy = createOciStaticEndpointPolicy({ + serviceId: OCI_SERVICE_ID, + serviceName: 'functions', + hostnameTemplate: 'regional-oci', + }) + const invocationPolicy = createOciDiscoveredEndpointPolicy({ + serviceId: OCI_SERVICE_ID, + serviceName: 'functions', + hostnameTemplate: 'region-first-oci', + responsePolicy: managementPolicy, + source: { kind: 'json', path: ['invokeEndpoint'] }, + }) + const invokeOrigin = 'https://fixture.us-ashburn-1.functions.oci.oraclecloud.com' + mocks.secureFetch + .mockResolvedValueOnce( + secureResponse({ body: JSON.stringify({ invokeEndpoint: invokeOrigin }) }) + ) + .mockResolvedValueOnce(secureResponse({ body: 'invoked' })) + const { client } = await createPreparedClient() + const managementEndpoint = await client.prepareStaticEndpoint(managementPolicy) + const response = await client.request({ + endpoint: managementEndpoint, + method: 'GET', + encodedPath: '/20181201/functions/synthetic-function', + timeoutMs: 10_000, + maxResponseBytes: 1024, + }) + const invocationEndpoint = await client.prepareDiscoveredEndpoint(invocationPolicy, response) + const body = new TextEncoder().encode('{"message":"hello"}') + const result = await client.request({ + endpoint: invocationEndpoint, + method: 'POST', + encodedPath: '/20181201/functions/synthetic-function/actions/invoke', + body, + contentType: 'application/json', + timeoutMs: 10_000, + maxResponseBytes: 1024, + }) + expect(mocks.secureFetch).toHaveBeenCalledTimes(2) + expect(mocks.secureFetch.mock.calls[0][0]).toBe( + 'https://functions.us-ashburn-1.oci.oraclecloud.com/20181201/functions/synthetic-function' + ) + expect(mocks.secureFetch.mock.calls[1][0]).toBe( + `${invokeOrigin}/20181201/functions/synthetic-function/actions/invoke` + ) + expect(mocks.secureFetch.mock.calls[1][2]).toMatchObject({ method: 'POST', body }) + expect(authorizationFromLastRequest()).toMatch(/^Signature version="1"/) + expect(new TextDecoder().decode(result.body)).toBe('invoked') + }) + it('rejects fabricated and cross-client authenticated discovery responses', async () => { const policy = createOciDiscoveredEndpointPolicy({ serviceId: OCI_SERVICE_ID, diff --git a/apps/sim/lib/internal/oci/endpoints.test.ts b/apps/sim/lib/internal/oci/endpoints.test.ts index b781863127e..a335f80a346 100644 --- a/apps/sim/lib/internal/oci/endpoints.test.ts +++ b/apps/sim/lib/internal/oci/endpoints.test.ts @@ -41,6 +41,9 @@ describe('OCI region registry', () => { expect(regionalOciHostname('objectstorage', region, 'regional')).toBe( `objectstorage.${id}.${region.realm.domain}` ) + expect(regionalOciHostname('functions', region, 'region-first-oci')).toBe( + `${id}.functions.oci.${region.realm.domain}` + ) } }) @@ -60,6 +63,79 @@ describe('OCI region registry', () => { describe('OCI endpoint policies', () => { const region = getOciRegion('us-ashburn-1') + const functionsManagementPolicy = createOciStaticEndpointPolicy({ + serviceId: OCI_SERVICE_ID, + serviceName: 'functions', + hostnameTemplate: 'regional-oci', + }) + const functionsInvocationPolicy = createOciDiscoveredEndpointPolicy({ + serviceId: OCI_SERVICE_ID, + serviceName: 'functions', + hostnameTemplate: 'region-first-oci', + responsePolicy: functionsManagementPolicy, + source: { kind: 'json', path: ['invokeEndpoint'] }, + }) + + it.each(['us-ashburn-1', 'us-gov-ashburn-1'])( + 'supports region-first policies and authenticated Functions discovery in %s', + (regionId) => { + const selectedRegion = getOciRegion(regionId) + const hostname = `${regionId}.functions.oci.${selectedRegion.realm.domain}` + expect( + resolveStaticOciEndpoint( + createOciStaticEndpointPolicy({ + serviceId: OCI_SERVICE_ID, + serviceName: 'functions', + hostnameTemplate: 'region-first-oci', + }), + selectedRegion + ).origin + ).toBe(`https://${hostname}`) + expect( + resolveDiscoveredOciEndpoint( + functionsInvocationPolicy, + selectedRegion, + `https://resource.${hostname}` + ) + ).toMatchObject({ + origin: `https://resource.${hostname}`, + serviceId: OCI_SERVICE_ID, + provenance: 'authenticated-discovery', + }) + expect(Object.isFrozen(functionsInvocationPolicy)).toBe(true) + expect(functionsInvocationPolicy.hostnameTemplate).toBe('region-first-oci') + expect(functionsInvocationPolicy.allowRegionalHost).toBe(false) + expect( + resolveDiscoveredOciEndpoint( + createOciDiscoveredEndpointPolicy({ + ...functionsInvocationPolicy, + allowRegionalHost: true, + }), + selectedRegion, + `https://${hostname}` + ).hostname + ).toBe(hostname) + } + ) + + it.each([ + 'https://resource.functions.us-ashburn-1.oci.oraclecloud.com', + 'https://resource.eu-frankfurt-1.functions.oci.oraclecloud.com', + 'https://resource.us-ashburn-1.functions.oci.oraclegovcloud.com', + 'https://resource.us-ashburn-1.database.oci.oraclecloud.com', + 'https://resource.us-ashburn-1.functions.oci.oraclecloud.com.attacker.example', + 'https://resourceus-ashburn-1.functions.oci.oraclecloud.com', + 'https://us-ashburn-1.functions.oci.oraclecloud.com', + 'http://resource.us-ashburn-1.functions.oci.oraclecloud.com', + 'https://resource.us-ashburn-1.functions.oci.oraclecloud.com:8443', + 'https://user:password@resource.us-ashburn-1.functions.oci.oraclecloud.com', + 'https://resource.us-ashburn-1.functions.oci.oraclecloud.com/path', + 'https://resource.us-ashburn-1.functions.oci.oraclecloud.com?query=1', + 'https://resource.us-ashburn-1.functions.oci.oraclecloud.com#fragment', + 'https://127.0.0.1', + ])('rejects invalid Functions invocation origins: %s', (origin) => { + expect(() => resolveDiscoveredOciEndpoint(functionsInvocationPolicy, region, origin)).toThrow() + }) it('freezes declarative policies and derives exact static origins', () => { expect(Object.isFrozen(staticPolicy)).toBe(true) diff --git a/apps/sim/lib/internal/oci/endpoints.ts b/apps/sim/lib/internal/oci/endpoints.ts index 9fe6f6881d3..5fd5c5b0363 100644 --- a/apps/sim/lib/internal/oci/endpoints.ts +++ b/apps/sim/lib/internal/oci/endpoints.ts @@ -2,7 +2,7 @@ import { isIpLiteral, unwrapIpv6Brackets } from '@sim/security/ssrf' import type { OAuthService } from '@/lib/oauth/types' export type OciDestinationProvenance = 'static' | 'authenticated-discovery' -export type OciHostnameTemplate = 'regional' | 'regional-oci' +export type OciHostnameTemplate = 'regional' | 'regional-oci' | 'region-first-oci' export interface OciRealm { readonly id: string @@ -216,7 +216,7 @@ function assertServiceName(value: string): void { } function assertHostnameTemplate(value: OciHostnameTemplate): void { - if (value !== 'regional' && value !== 'regional-oci') { + if (value !== 'regional' && value !== 'regional-oci' && value !== 'region-first-oci') { throw new Error('OCI endpoint policy hostname template is invalid') } } @@ -295,7 +295,10 @@ export function regionalOciHostname( assertServiceName(serviceName) assertHostnameTemplate(hostnameTemplate) const ociLabel = hostnameTemplate === 'regional-oci' ? '.oci' : '' - const hostname = `${serviceName}.${region.id}${ociLabel}.${region.realm.domain}` + const hostname = + hostnameTemplate === 'region-first-oci' + ? `${region.id}.${serviceName}.oci.${region.realm.domain}` + : `${serviceName}.${region.id}${ociLabel}.${region.realm.domain}` if (hostname.length > MAX_HOSTNAME_LENGTH) { throw new Error('OCI endpoint policy hostname is invalid') } From 1b74526e4121e4fa3f0d434c67dc0f00b0db3db7 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Sat, 5 Sep 2026 12:14:45 -0700 Subject: [PATCH 24/29] feat(oci): expose streaming and object storage response headers --- .../lib/internal/oci/client.server.test.ts | 217 +++++++++++++++++- apps/sim/lib/internal/oci/client.server.ts | 59 ++++- 2 files changed, 265 insertions(+), 11 deletions(-) diff --git a/apps/sim/lib/internal/oci/client.server.test.ts b/apps/sim/lib/internal/oci/client.server.test.ts index a234601fd7d..0148bf2ba2c 100644 --- a/apps/sim/lib/internal/oci/client.server.test.ts +++ b/apps/sim/lib/internal/oci/client.server.test.ts @@ -708,12 +708,34 @@ describe('credential-bound OCI client', () => { expect(JSON.stringify(failure)).not.toContain('authorization') }) - it('returns only selected safe headers and bounded Uint8Array bodies', async () => { + it.each([true, false])('keeps additional response headers opt-in: %s', async (requested) => { + const additionalHeaders = { + 'opc-next-cursor': 'opaque/next+cursor==%2F', + 'content-length': '3', + 'last-modified': 'Sat, 05 Sep 2026 12:00:00 GMT', + 'content-md5': 'content-md5==', + 'opc-content-md5': 'opc-content-md5==', + 'opc-multipart-md5': 'multipart-md5==', + 'content-encoding': 'identity', + 'content-language': 'en', + 'content-disposition': 'attachment; filename="report.csv"', + 'cache-control': 'private, max-age=60', + 'storage-tier': 'Archive', + 'archival-state': 'Restored', + 'time-of-archival': '2026-09-06T12:00:00Z', + 'version-id': 'opaque-version-id', + 'is-delete-marker': 'false', + } + const defaultHeaders = { + 'content-type': 'application/octet-stream', + etag: 'etag-1', + 'opc-request-id': 'request-1', + } mocks.secureFetch.mockResolvedValueOnce( secureResponse({ status: 200, body: new Uint8Array([1, 2, 3]), - headers: { etag: 'etag-1', 'x-provider-secret': 'hidden' }, + headers: { ...additionalHeaders, ...defaultHeaders, 'x-provider-secret': 'hidden' }, }) ) const { client, endpoint } = await createPreparedClient() @@ -721,13 +743,198 @@ describe('credential-bound OCI client', () => { endpoint, method: 'GET', encodedPath: '/v1/test', - responseHeaders: ['etag'], + responseHeaders: requested + ? ['ETAG', ...Object.keys(additionalHeaders).map((name) => name.toUpperCase())] + : undefined, timeoutMs: 10_000, maxResponseBytes: 3, }) expect([...result.body]).toEqual([1, 2, 3]) - expect(result.headers.etag).toBe('etag-1') - expect(result.headers).not.toHaveProperty('x-provider-secret') + expect(result.headers).toEqual({ + ...defaultHeaders, + ...(requested ? additionalHeaders : {}), + }) + expect(Object.isFrozen(result.headers)).toBe(true) + }) + + it('retains an opaque next cursor when the message batch is empty', async () => { + const cursor = 'opaque/next+cursor==%2F' + mocks.secureFetch.mockResolvedValueOnce( + secureResponse({ + body: '[]', + headers: { 'opc-next-cursor': cursor, 'opc-next-page': 'not-a-message-cursor' }, + }) + ) + const { client, endpoint } = await createPreparedClient() + const result = await client.request({ + endpoint, + method: 'GET', + encodedPath: '/v1/messages', + responseHeaders: ['opc-next-cursor'], + timeoutMs: 10_000, + maxResponseBytes: 1024, + }) + expect(new TextDecoder().decode(result.body)).toBe('[]') + expect(result.headers).toEqual({ 'opc-next-cursor': cursor }) + }) + + it('projects HEAD metadata without applying the body limit to the object size', async () => { + mocks.secureFetch.mockResolvedValueOnce( + secureResponse({ + headers: { 'content-length': '1099511627776', 'opc-meta-source': 'head metadata' }, + }) + ) + const { client, endpoint } = await createPreparedClient() + const result = await client.request({ + endpoint, + method: 'HEAD', + encodedPath: '/v1/object', + responseHeaders: ['content-length', 'opc-meta-*'], + timeoutMs: 10_000, + maxResponseBytes: 1, + }) + expect(result.body.byteLength).toBe(0) + expect(result.headers).toEqual({ + 'content-length': '1099511627776', + 'opc-meta-source': 'head metadata', + }) + }) + + it.each([true, false])( + 'projects only explicitly requested object metadata: %s', + async (requested) => { + mocks.secureFetch.mockResolvedValueOnce( + secureResponse({ + headers: { + 'OPC-Meta-Source': 'Mixed CASE / café', + 'opc-meta-empty': '', + 'opc-meta-': 'missing suffix', + 'x-opc-meta-secret': 'excluded', + 'set-cookie': 'excluded=secret', + authorization: 'Bearer excluded-secret', + }, + }) + ) + const { client, endpoint } = await createPreparedClient() + const result = await client.request({ + endpoint, + method: 'GET', + encodedPath: '/v1/object', + responseHeaders: requested ? ['OPC-META-*', 'opc-meta-*'] : [], + timeoutMs: 10_000, + maxResponseBytes: 1024, + }) + expect(result.headers).toEqual( + requested + ? { + 'opc-meta-source': 'Mixed CASE / café', + 'opc-meta-empty': '', + } + : {} + ) + expect(Object.isFrozen(result.headers)).toBe(true) + } + ) + + it.each([4096, 4097])('bounds projected object metadata entries: %i', async (count) => { + const headers: Record = {} + for (let index = 0; index < count; index += 1) { + headers[`opc-meta-${index}`] = '' + } + mocks.secureFetch.mockResolvedValueOnce(secureResponse({ headers })) + const { client, endpoint } = await createPreparedClient() + const pending = client.request({ + endpoint, + method: 'GET', + encodedPath: '/v1/object', + responseHeaders: ['opc-meta-*'], + timeoutMs: 10_000, + maxResponseBytes: 1024, + }) + if (count === 4096) { + const result = await pending + expect(result.headers).toEqual(headers) + expect(Object.isFrozen(result.headers)).toBe(true) + } else { + await expect(pending).rejects.toMatchObject({ + code: 'response_too_large', + message: 'OCI response exceeded the configured limit', + }) + } + expect(mocks.secureFetch).toHaveBeenCalledOnce() + }) + + it.each([65536, 65537])('bounds metadata names and values by UTF-8 bytes: %i', async (bytes) => { + const name = 'opc-meta-test' + const valueBytes = bytes - Buffer.byteLength(name, 'utf8') + const value = 'é'.repeat(Math.floor(valueBytes / 2)) + 'x'.repeat(valueBytes % 2) + expect(Buffer.byteLength(name + value, 'utf8')).toBe(bytes) + mocks.secureFetch.mockResolvedValueOnce(secureResponse({ headers: { [name]: value } })) + const { client, endpoint } = await createPreparedClient() + const pending = client.request({ + endpoint, + method: 'GET', + encodedPath: '/v1/object', + responseHeaders: ['opc-meta-*'], + timeoutMs: 10_000, + maxResponseBytes: 1024, + }) + if (bytes === 65536) { + expect((await pending).headers).toEqual({ [name]: value }) + } else { + await expect(pending).rejects.toMatchObject({ + code: 'response_too_large', + message: 'OCI response exceeded the configured limit', + }) + } + }) + + it('applies the metadata byte limit across entries only when requested', async () => { + const headers = { + 'opc-meta-first': 'a'.repeat(40_000), + 'opc-meta-second': 'b'.repeat(40_000), + } + mocks.secureFetch.mockResolvedValue(secureResponse({ headers })) + const { client, endpoint } = await createPreparedClient() + const request = { + endpoint, + method: 'GET' as const, + encodedPath: '/v1/object', + timeoutMs: 10_000, + maxResponseBytes: 1024, + } + expect((await client.request(request)).headers).toEqual({}) + mocks.secureFetch.mockResolvedValueOnce(secureResponse({ headers })) + await expect( + client.request({ ...request, responseHeaders: ['opc-meta-*'] }) + ).rejects.toMatchObject({ + code: 'response_too_large', + message: 'OCI response exceeded the configured limit', + }) + }) + + it.each([ + '*', + 'opc-*', + 'opc-meta-*suffix', + 'opc-meta-', + 'opc-meta-name', + 'set-cookie', + 'x-provider-secret', + ])('rejects unsupported header selectors before DNS or transport: %s', async (name) => { + const { client, endpoint } = await createPreparedClient() + await expect( + client.request({ + endpoint, + method: 'GET', + encodedPath: '/v1/object', + responseHeaders: [name], + timeoutMs: 10_000, + maxResponseBytes: 1024, + }) + ).rejects.toMatchObject({ code: 'invalid_request' }) + expect(mocks.validateUrl).not.toHaveBeenCalled() + expect(mocks.secureFetch).not.toHaveBeenCalled() }) it('cancels and classifies a success body beyond the operation limit', async () => { diff --git a/apps/sim/lib/internal/oci/client.server.ts b/apps/sim/lib/internal/oci/client.server.ts index 6b8e01b408a..1ea2437e30b 100644 --- a/apps/sim/lib/internal/oci/client.server.ts +++ b/apps/sim/lib/internal/oci/client.server.ts @@ -67,6 +67,7 @@ interface OciRequestBase { readonly headers?: Readonly> readonly timeoutMs: number readonly maxResponseBytes: number + /** Additional allowlisted headers; `opc-meta-*` selects bounded object metadata. */ readonly responseHeaders?: readonly string[] readonly signal?: AbortSignal } @@ -165,14 +166,33 @@ const SIGNING_CONTROLLED_HEADERS: ReadonlySet = new Set([ 'x-content-sha256', ]) const RESPONSE_HEADER_ALLOWLIST: ReadonlySet = new Set([ + 'archival-state', + 'cache-control', + 'content-disposition', + 'content-encoding', + 'content-language', + 'content-length', + 'content-md5', 'content-type', 'etag', + 'is-delete-marker', + 'last-modified', 'location', + 'opc-content-md5', + 'opc-multipart-md5', + 'opc-next-cursor', 'opc-next-page', 'opc-request-id', 'opc-work-request-id', 'retry-after', + 'storage-tier', + 'time-of-archival', + 'version-id', ]) +const OBJECT_METADATA_HEADER_PREFIX = 'opc-meta-' +const OBJECT_METADATA_HEADER_SELECTOR = 'opc-meta-*' +const MAX_OBJECT_METADATA_HEADERS = 4096 +const MAX_OBJECT_METADATA_HEADER_BYTES = 64 * 1024 const RETRYABLE_STATUSES: ReadonlySet = new Set([429, 500, 502, 503, 504]) const RETRYABLE_TRANSPORT_CODES: ReadonlySet = new Set([ 'ECONNRESET', @@ -526,7 +546,11 @@ function validateRequest(request: OciRequest): { throw new OciClientError('invalid_request') } for (const name of request.responseHeaders ?? []) { - if (typeof name !== 'string' || !RESPONSE_HEADER_ALLOWLIST.has(name.toLowerCase())) { + if ( + typeof name !== 'string' || + (!RESPONSE_HEADER_ALLOWLIST.has(name.toLowerCase()) && + name.toLowerCase() !== OBJECT_METADATA_HEADER_SELECTOR) + ) { throw new OciClientError('invalid_request') } } @@ -586,13 +610,36 @@ function selectedResponseHeaders( response: SecureFetchResponse, requested: readonly string[] ): Readonly> { - const selected = new Set(['content-type', 'etag', 'opc-request-id', ...requested.map(String)]) + const selected = new Set([ + 'content-type', + 'etag', + 'opc-request-id', + ...requested.map((name) => name.toLowerCase()), + ]) const result: Record = {} for (const name of selected) { - const normalized = name.toLowerCase() - if (!RESPONSE_HEADER_ALLOWLIST.has(normalized)) continue - const value = response.headers.get(normalized) - if (value !== null) result[normalized] = value + if (!RESPONSE_HEADER_ALLOWLIST.has(name)) continue + const value = response.headers.get(name) + if (value !== null) result[name] = value + } + if (selected.has(OBJECT_METADATA_HEADER_SELECTOR)) { + let count = 0 + let bytes = 0 + for (const [name, value] of response.headers) { + const normalized = name.toLowerCase() + if ( + !normalized.startsWith(OBJECT_METADATA_HEADER_PREFIX) || + normalized.length === OBJECT_METADATA_HEADER_PREFIX.length + ) { + continue + } + count += 1 + bytes += Buffer.byteLength(normalized, 'utf8') + Buffer.byteLength(value, 'utf8') + if (count > MAX_OBJECT_METADATA_HEADERS || bytes > MAX_OBJECT_METADATA_HEADER_BYTES) { + throw new OciClientError('response_too_large') + } + result[normalized] = value + } } return Object.freeze(result) } From 3fa59e758f5d282e95978aca7482a91a4f3dcee5 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Sat, 5 Sep 2026 12:16:27 -0700 Subject: [PATCH 25/29] fix(oci): preserve encoded resource path separators --- .../lib/internal/oci/client.server.test.ts | 70 ++++++++++++++++++- apps/sim/lib/internal/oci/client.server.ts | 3 +- 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/apps/sim/lib/internal/oci/client.server.test.ts b/apps/sim/lib/internal/oci/client.server.test.ts index 0148bf2ba2c..b09982ac4e1 100644 --- a/apps/sim/lib/internal/oci/client.server.test.ts +++ b/apps/sim/lib/internal/oci/client.server.test.ts @@ -372,6 +372,60 @@ describe('credential-bound OCI client', () => { }) }) + it.each([ + 'reports%2Fdaily.csv', + 'reports%2fdaily%5cfile.csv', + '%2Freports%2F%2Fdaily.csv', + '%2F', + '%5Creports%5Cdaily.csv', + 'reports%2F..%2Fdaily.csv', + 'reports%2F%E2%98%83%20caf%C3%A9.csv', + 'literal%252F%255C%2500.csv', + 'what%3Fpart%231.txt', + ])('preserves and signs an encoded object name exactly: %s', async (encodedName) => { + const { client } = await createPreparedClient() + const endpoint = await client.prepareStaticEndpoint( + createOciStaticEndpointPolicy({ + serviceId: OCI_SERVICE_ID, + serviceName: 'objectstorage', + hostnameTemplate: 'regional', + }) + ) + const encodedPath = `/n/synthetic_namespace/b/synthetic_bucket/o/${encodedName}` + const target = `${encodedPath}?versionId=v%2F1` + await client.request({ + endpoint, + method: 'GET', + encodedPath, + queryPairs: [['versionId', 'v/1']], + timeoutMs: 10_000, + maxResponseBytes: 1024, + }) + expect(mocks.secureFetch).toHaveBeenCalledOnce() + const [url, , options] = mocks.secureFetch.mock.calls[0] as [ + string, + string, + { headers: Record }, + ] + const hostname = 'objectstorage.us-ashburn-1.oraclecloud.com' + expect(url).toBe(`https://${hostname}${target}`) + const signature = options.headers.authorization.match(/signature="([^"]+)"/)?.[1] + expect(signature).toBeDefined() + const signingString = [ + `x-date: ${options.headers['x-date']}`, + `(request-target): get ${target}`, + `host: ${hostname}`, + ].join('\n') + expect( + verify( + 'RSA-SHA256', + Buffer.from(signingString, 'utf8'), + createPublicKey(PRIVATE_KEY), + Buffer.from(signature ?? '', 'base64') + ) + ).toBe(true) + }) + it.each(['GET', 'HEAD', 'DELETE'] as const)('rejects bodies for %s', async (method) => { const { client, endpoint } = await createPreparedClient() await expect( @@ -437,13 +491,23 @@ describe('credential-bound OCI client', () => { '//host/path', '/double//slash', '/query?x=1', + '/fragment#value', '/back\\slash', - '/encoded%2Fslash', - '/encoded%5Cbackslash', '/encoded%00control', '/encoded%1fcontrol', '/encoded%7Fcontrol', '/bad%2', + '/bad%GG', + '/raw\0control', + '/raw\u007fcontrol', + '/raw\ncontrol', + '/raw\tcontrol', + '/raw space', + '/raw\ud800surrogate', + '/a/../b', + '/a/./b', + '/a/%2e%2e/b', + '/a/%2E/b', ])('rejects ambiguous encoded paths: %s', async (encodedPath) => { const { client, endpoint } = await createPreparedClient() await expect( @@ -455,6 +519,8 @@ describe('credential-bound OCI client', () => { maxResponseBytes: 1024, }) ).rejects.toMatchObject({ code: 'invalid_request' }) + expect(mocks.validateUrl).not.toHaveBeenCalled() + expect(mocks.secureFetch).not.toHaveBeenCalled() }) it('rejects signing-controlled headers', async () => { diff --git a/apps/sim/lib/internal/oci/client.server.ts b/apps/sim/lib/internal/oci/client.server.ts index 1ea2437e30b..06d92f552bc 100644 --- a/apps/sim/lib/internal/oci/client.server.ts +++ b/apps/sim/lib/internal/oci/client.server.ts @@ -62,6 +62,7 @@ export type OciRetryPolicy = OciSafeRetryPolicy | OciTokenizedRetryPolicy interface OciRequestBase { readonly endpoint: OciPreparedEndpoint + /** Exact encoded path; encode each raw parameter once, including any embedded separators. */ readonly encodedPath: string readonly queryPairs?: readonly (readonly [string, string])[] readonly headers?: Readonly> @@ -418,7 +419,7 @@ function buildRequestUrl( encodedPath.startsWith('//') || encodedPath.includes('//') || /[?#\\\u0000-\u001f\u007f]/.test(encodedPath) || - /%(?:0[0-9a-f]|1[0-9a-f]|2f|5c|7f)/i.test(encodedPath) || + /%(?:0[0-9a-f]|1[0-9a-f]|7f)/i.test(encodedPath) || /%(?![0-9a-f]{2})/i.test(encodedPath) ) { throw new OciClientError('invalid_request') From 9df7986c0fe0a5a70d10c6196eb73945bd15cf04 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Mon, 7 Sep 2026 14:03:48 -0700 Subject: [PATCH 26/29] fix(oci): accept Oracle API key download markers --- ...oci-api-key-service-account.server.test.ts | 50 +++++++++++++++++++ .../oci-api-key-service-account.server.ts | 11 +++- .../orchestration/credential-create.ts | 2 +- .../credentials/orchestration/index.test.ts | 9 +++- .../lib/credentials/orchestration/index.ts | 2 +- 5 files changed, 68 insertions(+), 6 deletions(-) diff --git a/apps/sim/lib/credentials/oci-api-key-service-account.server.test.ts b/apps/sim/lib/credentials/oci-api-key-service-account.server.test.ts index 5d64bef30df..59558847f0e 100644 --- a/apps/sim/lib/credentials/oci-api-key-service-account.server.test.ts +++ b/apps/sim/lib/credentials/oci-api-key-service-account.server.test.ts @@ -101,6 +101,56 @@ describe('OCI API-key credential setup', () => { ) }) + it.each(['\n', '\r\n'])( + 'accepts the exact Oracle download marker with %j line endings', + async (newline) => { + for (const [pem, keyPassphrase] of [ + [privateKey, undefined], + [encryptedPrivateKey, passphrase], + ]) { + await verifyAndEncryptOciApiKeyCredential( + fields({ + privateKey: `${pem}OCI_API_KEY\n`.replaceAll('\n', newline), + privateKeyPassphrase: keyPassphrase, + }) + ) + const serialized = dependencies.verifySetup.mock.lastCall![0] + expect(JSON.parse(serialized).privateKey).toBe(pem) + expect(dependencies.encryptSecret).toHaveBeenLastCalledWith(serialized) + } + } + ) + + it('rejects malformed download trailers before provider verification', async () => { + for (const suffix of [ + 'OCI_API_KEY_EXTRA', + 'oci_api_key', + 'OCI_API_KEY\nextra', + 'extra\nOCI_API_KEY', + 'OCI_API_KEY\nOCI_API_KEY', + '\u0000OCI_API_KEY', + ]) { + await expect( + verifyAndEncryptOciApiKeyCredential(fields({ privateKey: `${privateKey}${suffix}` })) + ).rejects.toEqual(new OciCredentialVerificationError('invalid_credentials')) + } + await expect( + verifyAndEncryptOciApiKeyCredential( + fields({ privateKey: `${privateKey}${' '.repeat(65536)}\nOCI_API_KEY` }) + ) + ).rejects.toEqual(new OciCredentialVerificationError('invalid_credentials')) + await expect( + verifyAndEncryptOciApiKeyCredential( + fields({ + privateKey: `${privateKey}OCI_API_KEY`, + fingerprint: '00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00', + }) + ) + ).rejects.toEqual(new OciCredentialVerificationError('invalid_credentials')) + expect(dependencies.verifySetup).not.toHaveBeenCalled() + expect(dependencies.encryptSecret).not.toHaveBeenCalled() + }) + it('accepts encrypted RSA keys only with the exact preserved passphrase', async () => { await verifyAndEncryptOciApiKeyCredential( fields({ privateKey: encryptedPrivateKey, privateKeyPassphrase: passphrase }) diff --git a/apps/sim/lib/credentials/oci-api-key-service-account.server.ts b/apps/sim/lib/credentials/oci-api-key-service-account.server.ts index d076e6c185c..b988d8dc782 100644 --- a/apps/sim/lib/credentials/oci-api-key-service-account.server.ts +++ b/apps/sim/lib/credentials/oci-api-key-service-account.server.ts @@ -94,10 +94,17 @@ function normalizeFingerprint(value: unknown): string { function normalizePrivateKey(value: unknown): string { assertBoundedText(value, 'private key', MAX_PRIVATE_KEY_BYTES, PEM_CONTROL_CHARACTER_PATTERN) const normalized = value.replace(/\r\n?/g, '\n').trim() - if (!normalized.startsWith('-----BEGIN ') || !normalized.endsWith('-----')) { + const pem = normalized.endsWith('\nOCI_API_KEY') + ? normalized.slice(0, -'\nOCI_API_KEY'.length).trimEnd() + : normalized + if ( + !/^-----BEGIN (PRIVATE KEY|RSA PRIVATE KEY|ENCRYPTED PRIVATE KEY)-----\n[\s\S]+\n-----END \1-----$/.test( + pem + ) + ) { throw new Error('OCI private key must be PEM encoded') } - return `${normalized}\n` + return `${pem}\n` } function validatePassphrase(value: unknown): string | undefined { diff --git a/apps/sim/lib/credentials/orchestration/credential-create.ts b/apps/sim/lib/credentials/orchestration/credential-create.ts index 5f13e631eb0..d932175af5c 100644 --- a/apps/sim/lib/credentials/orchestration/credential-create.ts +++ b/apps/sim/lib/credentials/orchestration/credential-create.ts @@ -545,7 +545,7 @@ export async function createCredentialRecord( return failure( providerUnavailable ? 'OCI is temporarily unavailable for credential verification' - : 'OCI rejected the API-key credential', + : 'OCI API-key credential could not be verified', 'validation', { providerErrorCode, providerUnavailable } ) diff --git a/apps/sim/lib/credentials/orchestration/index.test.ts b/apps/sim/lib/credentials/orchestration/index.test.ts index fc898f885f1..5f69d1aafbf 100644 --- a/apps/sim/lib/credentials/orchestration/index.test.ts +++ b/apps/sim/lib/credentials/orchestration/index.test.ts @@ -230,7 +230,7 @@ describe('performUpdateCredential — service-account secret rotation', () => { }) it.each([ - ['invalid_credentials', 'invalid_credentials', 'OCI rejected the API-key credential'], + ['invalid_credentials', 'invalid_credentials', 'OCI API-key credential could not be verified'], [ 'service_unavailable', 'provider_unavailable', @@ -724,7 +724,12 @@ describe('createServiceAccountCredential', () => { }) it.each([ - ['invalid_credentials', 'invalid_credentials', false, 'OCI rejected the API-key credential'], + [ + 'invalid_credentials', + 'invalid_credentials', + false, + 'OCI API-key credential could not be verified', + ], [ 'service_unavailable', 'provider_unavailable', diff --git a/apps/sim/lib/credentials/orchestration/index.ts b/apps/sim/lib/credentials/orchestration/index.ts index ad778a79e97..5ebcf309e6a 100644 --- a/apps/sim/lib/credentials/orchestration/index.ts +++ b/apps/sim/lib/credentials/orchestration/index.ts @@ -412,7 +412,7 @@ export async function updateCredentialRecord( success: false, error: providerUnavailable ? 'OCI is temporarily unavailable for credential verification' - : 'OCI rejected the API-key credential', + : 'OCI API-key credential could not be verified', errorCode: 'validation', providerErrorCode: providerUnavailable ? 'provider_unavailable' : 'invalid_credentials', } From 22df743ef9b04d8eeed34a9d8084b33173d48de5 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Mon, 7 Sep 2026 14:07:34 -0700 Subject: [PATCH 27/29] fix(oci): require a single downloaded PEM key block --- .../lib/credentials/oci-api-key-service-account.server.test.ts | 2 ++ apps/sim/lib/credentials/oci-api-key-service-account.server.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/sim/lib/credentials/oci-api-key-service-account.server.test.ts b/apps/sim/lib/credentials/oci-api-key-service-account.server.test.ts index 59558847f0e..22ef6747a90 100644 --- a/apps/sim/lib/credentials/oci-api-key-service-account.server.test.ts +++ b/apps/sim/lib/credentials/oci-api-key-service-account.server.test.ts @@ -129,6 +129,8 @@ describe('OCI API-key credential setup', () => { 'extra\nOCI_API_KEY', 'OCI_API_KEY\nOCI_API_KEY', '\u0000OCI_API_KEY', + privateKey, + `extra\n-----END PRIVATE KEY-----`, ]) { await expect( verifyAndEncryptOciApiKeyCredential(fields({ privateKey: `${privateKey}${suffix}` })) diff --git a/apps/sim/lib/credentials/oci-api-key-service-account.server.ts b/apps/sim/lib/credentials/oci-api-key-service-account.server.ts index b988d8dc782..46b92ceb0c7 100644 --- a/apps/sim/lib/credentials/oci-api-key-service-account.server.ts +++ b/apps/sim/lib/credentials/oci-api-key-service-account.server.ts @@ -98,7 +98,7 @@ function normalizePrivateKey(value: unknown): string { ? normalized.slice(0, -'\nOCI_API_KEY'.length).trimEnd() : normalized if ( - !/^-----BEGIN (PRIVATE KEY|RSA PRIVATE KEY|ENCRYPTED PRIVATE KEY)-----\n[\s\S]+\n-----END \1-----$/.test( + !/^-----BEGIN (PRIVATE KEY|RSA PRIVATE KEY|ENCRYPTED PRIVATE KEY)-----\n(?:(?!-----BEGIN |-----END )[\s\S])+\n-----END \1-----$/.test( pem ) ) { From c8e1f3ef66c7a000633b6c2c8ebb6b31e8ea920a Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Sat, 5 Sep 2026 14:25:38 -0700 Subject: [PATCH 28/29] feat(oci-monitoring): add native Monitoring integration --- apps/docs/components/ui/icon-mapping.ts | 1 + apps/docs/content/docs/integrations/meta.json | 1 + .../docs/integrations/oci_monitoring.mdx | 539 ++++++ apps/sim/blocks/blocks/oci_monitoring.ts | 1459 +++++++++++++++++ apps/sim/blocks/registry-maps.ts | 3 + .../lib/copilot/generated/docs-manifest.ts | 1 + apps/sim/lib/integrations/icon-mapping.ts | 1 + .../oci-monitoring/execute-tool.test.ts | 270 +++ .../internal/oci-monitoring/execute-tool.ts | 124 ++ apps/sim/lib/internal/oci-monitoring/input.ts | 296 ++++ .../oci-monitoring/operations.test.ts | 708 ++++++++ .../lib/internal/oci-monitoring/operations.ts | 548 +++++++ .../tool-operations/registry.server.ts | 23 + apps/sim/lib/oauth/oauth.ts | 10 + apps/sim/lib/oauth/types.ts | 1 + apps/sim/lib/selectors/manifest.test.ts | 8 +- apps/sim/lib/selectors/manifest.ts | 20 + .../server/providers/oci-monitoring.test.ts | 378 +++++ .../server/providers/oci-monitoring.ts | 178 ++ apps/sim/lib/selectors/server/registry.ts | 2 + apps/sim/lib/selectors/types.ts | 4 + apps/sim/tools/generated/tool-ids.ts | 2 +- apps/sim/tools/generated/tool-metadata.ts | 2 +- apps/sim/tools/generated/tool-outputs.ts | 2 +- apps/sim/tools/oci_monitoring/create_alarm.ts | 218 +++ .../create_alarm_suppression.ts | 101 ++ apps/sim/tools/oci_monitoring/delete_alarm.ts | 43 + .../delete_alarm_suppression.ts | 43 + apps/sim/tools/oci_monitoring/get_alarm.ts | 35 + .../tools/oci_monitoring/get_alarm_history.ts | 71 + .../oci_monitoring/get_alarm_suppression.ts | 35 + apps/sim/tools/oci_monitoring/index.ts | 18 + .../oci_monitoring/list_alarm_suppressions.ts | 91 + apps/sim/tools/oci_monitoring/list_alarms.ts | 86 + .../oci_monitoring/list_alarms_status.ts | 107 ++ apps/sim/tools/oci_monitoring/list_metrics.ts | 109 ++ .../tools/oci_monitoring/post_metric_data.ts | 44 + .../remove_alarm_suppression.ts | 43 + .../retrieve_dimension_states.ts | 64 + .../summarize_alarm_suppression_history.ts | 73 + .../oci_monitoring/summarize_metrics_data.ts | 105 ++ apps/sim/tools/oci_monitoring/types.ts | 1027 ++++++++++++ apps/sim/tools/oci_monitoring/update_alarm.ts | 225 +++ apps/sim/tools/oci_monitoring/utils.ts | 27 + apps/sim/tools/registry.ts | 37 + .../deployment-config/src/integrations.json | 87 + 46 files changed, 7263 insertions(+), 7 deletions(-) create mode 100644 apps/docs/content/docs/integrations/oci_monitoring.mdx create mode 100644 apps/sim/blocks/blocks/oci_monitoring.ts create mode 100644 apps/sim/lib/internal/oci-monitoring/execute-tool.test.ts create mode 100644 apps/sim/lib/internal/oci-monitoring/execute-tool.ts create mode 100644 apps/sim/lib/internal/oci-monitoring/input.ts create mode 100644 apps/sim/lib/internal/oci-monitoring/operations.test.ts create mode 100644 apps/sim/lib/internal/oci-monitoring/operations.ts create mode 100644 apps/sim/lib/selectors/server/providers/oci-monitoring.test.ts create mode 100644 apps/sim/lib/selectors/server/providers/oci-monitoring.ts create mode 100644 apps/sim/tools/oci_monitoring/create_alarm.ts create mode 100644 apps/sim/tools/oci_monitoring/create_alarm_suppression.ts create mode 100644 apps/sim/tools/oci_monitoring/delete_alarm.ts create mode 100644 apps/sim/tools/oci_monitoring/delete_alarm_suppression.ts create mode 100644 apps/sim/tools/oci_monitoring/get_alarm.ts create mode 100644 apps/sim/tools/oci_monitoring/get_alarm_history.ts create mode 100644 apps/sim/tools/oci_monitoring/get_alarm_suppression.ts create mode 100644 apps/sim/tools/oci_monitoring/index.ts create mode 100644 apps/sim/tools/oci_monitoring/list_alarm_suppressions.ts create mode 100644 apps/sim/tools/oci_monitoring/list_alarms.ts create mode 100644 apps/sim/tools/oci_monitoring/list_alarms_status.ts create mode 100644 apps/sim/tools/oci_monitoring/list_metrics.ts create mode 100644 apps/sim/tools/oci_monitoring/post_metric_data.ts create mode 100644 apps/sim/tools/oci_monitoring/remove_alarm_suppression.ts create mode 100644 apps/sim/tools/oci_monitoring/retrieve_dimension_states.ts create mode 100644 apps/sim/tools/oci_monitoring/summarize_alarm_suppression_history.ts create mode 100644 apps/sim/tools/oci_monitoring/summarize_metrics_data.ts create mode 100644 apps/sim/tools/oci_monitoring/types.ts create mode 100644 apps/sim/tools/oci_monitoring/update_alarm.ts create mode 100644 apps/sim/tools/oci_monitoring/utils.ts diff --git a/apps/docs/components/ui/icon-mapping.ts b/apps/docs/components/ui/icon-mapping.ts index 417f6b6df18..1afc5c92ae9 100644 --- a/apps/docs/components/ui/icon-mapping.ts +++ b/apps/docs/components/ui/icon-mapping.ts @@ -482,6 +482,7 @@ export const blockTypeToIconMap: Record = { notion: NotionIcon, notion_v2: NotionIcon, obsidian: ObsidianIcon, + oci_monitoring: NetSuiteIcon, okta: OktaIcon, onedrive: MicrosoftOneDriveIcon, onepassword: OnePasswordIcon, diff --git a/apps/docs/content/docs/integrations/meta.json b/apps/docs/content/docs/integrations/meta.json index df2c5f2d169..add39682333 100644 --- a/apps/docs/content/docs/integrations/meta.json +++ b/apps/docs/content/docs/integrations/meta.json @@ -185,6 +185,7 @@ "notion", "notion-service-account", "obsidian", + "oci_monitoring", "okta", "onedrive", "onepassword", diff --git a/apps/docs/content/docs/integrations/oci_monitoring.mdx b/apps/docs/content/docs/integrations/oci_monitoring.mdx new file mode 100644 index 00000000000..49054153e01 --- /dev/null +++ b/apps/docs/content/docs/integrations/oci_monitoring.mdx @@ -0,0 +1,539 @@ +--- +title: OCI Monitoring +description: Discover and query metrics, publish custom metrics, and manage OCI alarms +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +## Usage Instructions + +Connect a reusable OCI API signing-key credential to discover metrics, run MQL queries, publish custom metrics, and manage alarms and maintenance suppressions. Reads and histories return one bounded page; queries expose explicit stream and datapoint budgets. Alarm destinations reference existing Notifications topics or Streaming streams. IAM policies must grant the selected operations in the alarm and metric compartments; creating and updating alarms also require metric-read access. Use the separate OCI Logging integration for logs. + + + +## Actions + +### OCI Monitoring List Metrics + +Discover metric definitions and namespaces + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `oauthCredential` | string | Yes | Reusable OCI API signing-key credential. | +| `region` | string | No | OCI region override; omit to use the credential region. | +| `compartmentId` | string | Yes | Compartment OCID. Subtree queries require the tenancy OCID and tenancy-level access. | +| `namespace` | string | No | Metric namespace, such as oci_computeagent. | +| `name` | string | No | Exact metric name to discover. | +| `resourceGroup` | string | No | Metric resource group; null matches metrics without a resource group. | +| `dimensionFilters` | json | No | JSON object of dimension names and exact string values. Metric discovery ignores this when groupBy is supplied. | +| `groupBy` | json | No | JSON array of namespace, name, or resourceGroup. When present, dimensionFilters is ignored. | +| `sortBy` | string | No | Sort field: NAMESPACE, NAME, or RESOURCEGROUP. | +| `sortOrder` | string | No | Sort direction: ASC or DESC. | +| `compartmentIdInSubtree` | boolean | No | Include child compartments; requires the tenancy OCID and tenancy-level permissions. | +| `limit` | number | No | Maximum results on this page: 1–1000; defaults to 100. | +| `page` | string | No | Opaque nextPage from the previous response. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `opcRequestId` | string | Oracle request ID | +| `metrics` | array | Discovered metric definitions; grouping can omit non-grouped fields | +| `nextPage` | string | Opaque token for the next page; null when complete | + +### OCI Monitoring Query Metrics + +Query and aggregate metric time series with MQL + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `oauthCredential` | string | Yes | Reusable OCI API signing-key credential. | +| `region` | string | No | OCI region override; omit to use the credential region. | +| `compartmentId` | string | Yes | Compartment OCID. Subtree queries require the tenancy OCID and tenancy-level access. | +| `namespace` | string | Yes | Metric namespace, such as oci_computeagent. | +| `query` | string | Yes | Oracle MQL expression, such as CpuUtilization\[1m\].mean\(\); alarm expressions also require a threshold or absence condition. | +| `resourceGroup` | string | No | Metric resource group; null matches metrics without a resource group. | +| `startTime` | string | No | Inclusive RFC3339 query start. Oracle defaults to three hours before the request. | +| `endTime` | string | No | Exclusive RFC3339 query end. Oracle defaults to request time. | +| `resolution` | string | No | Aggregation window spacing, no greater than the MQL interval. Queries accept 1m–60m, 1h–24h, or 1d; alarms accept only 1m. | +| `compartmentIdInSubtree` | boolean | No | Include child compartments; requires the tenancy OCID and tenancy-level permissions. | +| `maxStreams` | number | No | Maximum returned metric streams: defaults to 100, up to 2000. Excess results fail without truncation. | +| `maxDatapoints` | number | No | Maximum total returned datapoints: defaults to 10000, up to 100000. Excess results fail without truncation. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `opcRequestId` | string | Oracle request ID | +| `metricData` | array | Aggregated metric time series | +| ↳ `name` | string | Metric name | +| ↳ `namespace` | string | Metric namespace | +| ↳ `resourceGroup` | string | Resource group | +| ↳ `compartmentId` | string | Metric compartment OCID | +| ↳ `dimensions` | json | Dimension names and string values | +| ↳ `metadata` | json | Metric metadata such as unit | +| ↳ `resolution` | string | Aggregation spacing | +| ↳ `aggregatedDatapoints` | array | Aggregated datapoints | +| ↳ `timestamp` | string | Datapoint timestamp \(RFC3339\) | +| ↳ `value` | number | Aggregated value | + +### OCI Monitoring Publish Metrics + +Publish custom metric datapoints in one bounded request + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `oauthCredential` | string | Yes | Reusable OCI API signing-key credential. | +| `region` | string | No | OCI region override; omit to use the credential region. | +| `metricData` | json | Yes | JSON array of custom metric records \(compartmentId, namespace, name, dimensions, datapoints\). Sim allows 50 records and 1 MiB per request. Timestamps must be less than two hours old and less than ten minutes ahead. | +| `batchAtomicity` | string | No | ATOMIC rejects the whole batch on validation failure; NON_ATOMIC permits partial acceptance. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `opcRequestId` | string | Oracle request ID | +| `failedMetricsCount` | number | Number of rejected metric records | +| `failedMetrics` | array | Rejected metric records and their validation errors | +| ↳ `message` | string | Validation failure reason | +| ↳ `metricData` | json | Metric record rejected by Oracle | +| ↳ `name` | string | Metric name | +| ↳ `namespace` | string | Metric namespace | +| ↳ `resourceGroup` | string | Resource group | +| ↳ `compartmentId` | string | Metric compartment OCID | +| ↳ `dimensions` | json | Dimension names and string values | +| ↳ `datapoints` | array | Submitted datapoints | +| ↳ `timestamp` | string | Raw datapoint timestamp | +| ↳ `value` | number | Raw metric value | +| ↳ `count` | number | Sample count | +| ↳ `metadata` | json | Metric metadata | + +### OCI Monitoring List Alarms + +List alarm definitions in a compartment + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `oauthCredential` | string | Yes | Reusable OCI API signing-key credential. | +| `region` | string | No | OCI region override; omit to use the credential region. | +| `compartmentId` | string | Yes | Compartment OCID. Subtree queries require the tenancy OCID and tenancy-level access. | +| `displayName` | string | No | Alarm or suppression display name; list operations use exact matching. | +| `lifecycleState` | string | No | Lifecycle state: ACTIVE, DELETING, or DELETED. Omit for ACTIVE. | +| `sortBy` | string | No | Sort field: displayName or severity. | +| `sortOrder` | string | No | Sort direction: ASC or DESC. | +| `compartmentIdInSubtree` | boolean | No | Include child compartments; requires the tenancy OCID and tenancy-level permissions. | +| `limit` | number | No | Maximum results on this page: 1–1000; defaults to 100. | +| `page` | string | No | Opaque nextPage from the previous response. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `opcRequestId` | string | Oracle request ID | +| `alarms` | array | Alarm definitions | +| `nextPage` | string | Opaque token for the next page; null when complete | + +### OCI Monitoring List Alarm Statuses + +List current aggregate alarm statuses + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `oauthCredential` | string | Yes | Reusable OCI API signing-key credential. | +| `region` | string | No | OCI region override; omit to use the credential region. | +| `compartmentId` | string | Yes | Compartment OCID. Subtree queries require the tenancy OCID and tenancy-level access. | +| `displayName` | string | No | Alarm or suppression display name; list operations use exact matching. | +| `sortBy` | string | No | Sort field: displayName or severity. | +| `sortOrder` | string | No | Sort direction: ASC or DESC. | +| `resourceId` | string | No | Filter alarm status by monitored resource OCID. | +| `serviceName` | string | No | Filter by the exact service-name dimension. | +| `entityId` | string | No | Filter by monitored entity OCID. | +| `status` | string | No | Alarm status: FIRING or OK. | +| `compartmentIdInSubtree` | boolean | No | Include child compartments; requires the tenancy OCID and tenancy-level permissions. | +| `limit` | number | No | Maximum results on this page: 1–1000; defaults to 100. | +| `page` | string | No | Opaque nextPage from the previous response. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `opcRequestId` | string | Oracle request ID | +| `alarmStatuses` | array | Aggregate alarm statuses | +| ↳ `id` | string | Alarm OCID | +| ↳ `displayName` | string | Display name | +| ↳ `severity` | string | Severity | +| ↳ `ruleName` | string | Rule name | +| ↳ `timestampTriggered` | string | Last trigger timestamp | +| ↳ `alarmSummary` | string | Alarm summary | +| ↳ `status` | string | Aggregate alarm status | +| ↳ `suppression` | json | Inline alarm suppression | +| ↳ `description` | string | Suppression reason | +| ↳ `timeSuppressFrom` | string | Inclusive suppression start \(RFC3339\) | +| ↳ `timeSuppressUntil` | string | Inclusive suppression end \(RFC3339\) | +| `nextPage` | string | Opaque token for the next page; null when complete | + +### OCI Monitoring Get Alarm + +Get an alarm configuration and ETag + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `oauthCredential` | string | Yes | Reusable OCI API signing-key credential. | +| `region` | string | No | OCI region override; omit to use the credential region. | +| `alarmId` | string | Yes | Alarm OCID. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `opcRequestId` | string | Oracle request ID | +| `alarm` | json | Alarm configuration | +| `etag` | string | Concurrency ETag | + +### OCI Monitoring Get Alarm History + +Get paginated alarm state and rule history + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `oauthCredential` | string | Yes | Reusable OCI API signing-key credential. | +| `region` | string | No | OCI region override; omit to use the credential region. | +| `alarmId` | string | Yes | Alarm OCID. | +| `alarmHistorytype` | string | No | STATE_HISTORY, STATE_TRANSITION_HISTORY, RULE_HISTORY, or RULE_TRANSITION_HISTORY. | +| `timestampGreaterThanOrEqualTo` | string | No | Inclusive RFC3339 lower bound for alarm history. | +| `timestampLessThan` | string | No | Exclusive RFC3339 upper bound for alarm history. | +| `limit` | number | No | Maximum results on this page: 1–1000; defaults to 100. | +| `page` | string | No | Opaque nextPage from the previous response. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `opcRequestId` | string | Oracle request ID | +| `alarmId` | string | Alarm OCID | +| `isEnabled` | boolean | Whether the alarm is enabled | +| `history` | array | Alarm history entries | +| ↳ `alarmSummary` | string | Alarm summary | +| ↳ `summary` | string | History entry summary | +| ↳ `timestamp` | string | Entry timestamp | +| ↳ `timestampTriggered` | string | Trigger timestamp | +| `nextPage` | string | Opaque token for the next page; null when complete | + +### OCI Monitoring Get Dimension States + +Inspect the status of individual metric streams in an alarm + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `oauthCredential` | string | Yes | Reusable OCI API signing-key credential. | +| `region` | string | No | OCI region override; omit to use the credential region. | +| `alarmId` | string | Yes | Alarm OCID. | +| `dimensionFilters` | json | No | JSON object matching the exact complete set of dimension names and values for an alarm state entry. Partial dimension sets do not match. | +| `status` | string | No | Alarm status: FIRING or OK. | +| `limit` | number | No | Maximum results on this page: 1–1000; defaults to 100. | +| `page` | string | No | Opaque nextPage from the previous response. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `opcRequestId` | string | Oracle request ID | +| `alarmId` | string | Alarm OCID | +| `isEnabled` | boolean | Whether the alarm is enabled | +| `isNotificationsPerMetricDimensionEnabled` | boolean | Whether notifications are split | +| `dimensionStates` | array | Individual metric stream states | +| ↳ `alarmSummary` | string | Alarm summary | +| ↳ `dimensions` | json | Metric stream dimensions | +| ↳ `status` | string | Metric stream status | +| ↳ `ruleName` | string | Rule name | +| ↳ `timestamp` | string | State timestamp | +| `nextPage` | string | Opaque token for the next page; null when complete | + +### OCI Monitoring Create Alarm + +Create a metric alarm using existing notification destinations + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `oauthCredential` | string | Yes | Reusable OCI API signing-key credential. | +| `region` | string | No | OCI region override; omit to use the credential region. | +| `displayName` | string | Yes | Alarm or suppression display name; list operations use exact matching. | +| `compartmentId` | string | Yes | Compartment OCID. Subtree queries require the tenancy OCID and tenancy-level access. | +| `metricCompartmentId` | string | Yes | Compartment OCID containing the metrics evaluated by the alarm. | +| `namespace` | string | Yes | Metric namespace, such as oci_computeagent. | +| `query` | string | Yes | Oracle MQL expression, such as CpuUtilization\[1m\].mean\(\); alarm expressions also require a threshold or absence condition. | +| `severity` | string | Yes | Alarm severity: CRITICAL, ERROR, WARNING, or INFO. | +| `destinations` | json | Yes | JSON array of existing Notifications topic or Streaming stream OCIDs; at most one per destination service. | +| `isEnabled` | boolean | Yes | Whether the alarm evaluates its MQL condition. Omit on update to preserve the existing value. | +| `metricCompartmentIdInSubtree` | boolean | No | Evaluate metrics in child compartments; requires tenancy-level metric access. | +| `resourceGroup` | string | No | Metric resource group; null matches metrics without a resource group. | +| `resolution` | string | No | Aggregation window spacing, no greater than the MQL interval. Queries accept 1m–60m, 1h–24h, or 1d; alarms accept only 1m. | +| `pendingDuration` | string | No | ISO 8601 duration before firing, from PT1M to PT1H. | +| `body` | string | No | Alarm notification message body. | +| `isNotificationsPerMetricDimensionEnabled` | boolean | No | Send separate notifications per metric stream; required by Oracle for dimension-specific suppression. | +| `messageFormat` | string | No | Notification format: RAW, PRETTY_JSON, or ONS_OPTIMIZED. | +| `repeatNotificationDuration` | string | No | ISO 8601 interval for repeating notifications. | +| `suppression` | json | No | JSON object with timeSuppressFrom, timeSuppressUntil, and optional description. Use Remove Alarm Suppression to clear it. | +| `freeformTags` | json | No | JSON object of string tag names and values. | +| `definedTags` | json | No | JSON object of tag namespaces and values. | +| `overrides` | json | No | JSON array of up to two alarm trigger-rule overrides. | +| `ruleName` | string | No | Name of the base alarm rule. | +| `notificationVersion` | string | No | Oracle alarm notification version. | +| `notificationTitle` | string | No | Custom title for alarm notifications. | +| `evaluationSlackDuration` | string | No | ISO 8601 ingestion delay before evaluation, from PT3M to PT2H. | +| `alarmSummary` | string | No | Custom alarm summary. | +| `opcRetryToken` | string | No | Stable retry token for creation. Enables bounded tokenized retries; reuse it for the same logical creation. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `opcRequestId` | string | Oracle request ID | +| `alarm` | json | Alarm configuration | +| `etag` | string | Concurrency ETag | + +### OCI Monitoring Update Alarm + +Update supplied alarm fields using an optional ETag + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `oauthCredential` | string | Yes | Reusable OCI API signing-key credential. | +| `region` | string | No | OCI region override; omit to use the credential region. | +| `alarmId` | string | Yes | Alarm OCID. | +| `ifMatch` | string | No | ETag returned by a previous read; the mutation succeeds only if it still matches. | +| `displayName` | string | No | Alarm or suppression display name; list operations use exact matching. | +| `compartmentId` | string | No | Compartment OCID. Subtree queries require the tenancy OCID and tenancy-level access. | +| `metricCompartmentId` | string | No | Compartment OCID containing the metrics evaluated by the alarm. | +| `namespace` | string | No | Metric namespace, such as oci_computeagent. | +| `query` | string | No | Oracle MQL expression, such as CpuUtilization\[1m\].mean\(\); alarm expressions also require a threshold or absence condition. | +| `severity` | string | No | Alarm severity: CRITICAL, ERROR, WARNING, or INFO. | +| `destinations` | json | No | JSON array of existing Notifications topic or Streaming stream OCIDs; at most one per destination service. | +| `isEnabled` | boolean | No | Whether the alarm evaluates its MQL condition. Omit on update to preserve the existing value. | +| `metricCompartmentIdInSubtree` | boolean | No | Evaluate metrics in child compartments; requires tenancy-level metric access. | +| `resourceGroup` | string | No | Metric resource group; null matches metrics without a resource group. | +| `resolution` | string | No | Aggregation window spacing, no greater than the MQL interval. Queries accept 1m–60m, 1h–24h, or 1d; alarms accept only 1m. | +| `pendingDuration` | string | No | ISO 8601 duration before firing, from PT1M to PT1H. | +| `body` | string | No | Alarm notification message body. | +| `isNotificationsPerMetricDimensionEnabled` | boolean | No | Send separate notifications per metric stream; required by Oracle for dimension-specific suppression. | +| `messageFormat` | string | No | Notification format: RAW, PRETTY_JSON, or ONS_OPTIMIZED. | +| `repeatNotificationDuration` | string | No | ISO 8601 interval for repeating notifications. | +| `suppression` | json | No | JSON object with timeSuppressFrom, timeSuppressUntil, and optional description. Use Remove Alarm Suppression to clear it. | +| `freeformTags` | json | No | JSON object of string tag names and values. | +| `definedTags` | json | No | JSON object of tag namespaces and values. | +| `overrides` | json | No | JSON array of up to two alarm trigger-rule overrides. | +| `ruleName` | string | No | Name of the base alarm rule. | +| `notificationVersion` | string | No | Oracle alarm notification version. | +| `notificationTitle` | string | No | Custom title for alarm notifications. | +| `evaluationSlackDuration` | string | No | ISO 8601 ingestion delay before evaluation, from PT3M to PT2H. | +| `alarmSummary` | string | No | Custom alarm summary. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `opcRequestId` | string | Oracle request ID | +| `alarm` | json | Alarm configuration | +| `etag` | string | Concurrency ETag | + +### OCI Monitoring Delete Alarm + +Delete an alarm using an optional ETag + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `oauthCredential` | string | Yes | Reusable OCI API signing-key credential. | +| `region` | string | No | OCI region override; omit to use the credential region. | +| `alarmId` | string | Yes | Alarm OCID. | +| `ifMatch` | string | No | ETag returned by a previous read; the mutation succeeds only if it still matches. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `opcRequestId` | string | Oracle request ID | + +### OCI Monitoring Create Alarm Suppression + +Schedule alarm-wide or dimension-specific suppression + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `oauthCredential` | string | Yes | Reusable OCI API signing-key credential. | +| `region` | string | No | OCI region override; omit to use the credential region. | +| `alarmId` | string | Yes | Alarm OCID. | +| `displayName` | string | Yes | Alarm or suppression display name; list operations use exact matching. | +| `description` | string | No | Reason for the suppression. | +| `level` | string | No | ALARM for the whole alarm or DIMENSION for specific metric streams. Sim defaults new suppressions to ALARM. | +| `dimensions` | json | No | JSON object of dimension names and single string values; required for dimension suppression. | +| `timeSuppressFrom` | string | Yes | Inclusive RFC3339 start of suppression. | +| `timeSuppressUntil` | string | Yes | Inclusive RFC3339 end of suppression. | +| `opcRetryToken` | string | No | Stable retry token for creation. Enables bounded tokenized retries; reuse it for the same logical creation. | +| `freeformTags` | json | No | JSON object of string tag names and values. | +| `definedTags` | json | No | JSON object of tag namespaces and values. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `opcRequestId` | string | Oracle request ID | +| `alarmSuppression` | json | Suppression configuration | +| `etag` | string | Concurrency ETag | + +### OCI Monitoring List Alarm Suppressions + +List suppressions affecting an alarm + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `oauthCredential` | string | Yes | Reusable OCI API signing-key credential. | +| `region` | string | No | OCI region override; omit to use the credential region. | +| `alarmId` | string | Yes | Alarm OCID. | +| `displayName` | string | No | Alarm or suppression display name; list operations use exact matching. | +| `lifecycleState` | string | No | Lifecycle state: ACTIVE or DELETED. Omit for ACTIVE. | +| `level` | string | No | ALARM for the whole alarm or DIMENSION for specific metric streams. | +| `isAllSuppressions` | boolean | No | Include compartment or tenancy suppressions affecting the selected alarm. | +| `sortBy` | string | No | Sort field: displayName, timeCreated, or timeSuppressFrom. | +| `sortOrder` | string | No | Sort direction: ASC or DESC. | +| `limit` | number | No | Maximum results on this page: 1–1000; defaults to 100. | +| `page` | string | No | Opaque nextPage from the previous response. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `opcRequestId` | string | Oracle request ID | +| `alarmSuppressions` | array | Suppression resources | +| `nextPage` | string | Opaque token for the next page; null when complete | + +### OCI Monitoring Get Alarm Suppression + +Get a suppression configuration and ETag + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `oauthCredential` | string | Yes | Reusable OCI API signing-key credential. | +| `region` | string | No | OCI region override; omit to use the credential region. | +| `alarmSuppressionId` | string | Yes | Alarm suppression OCID. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `opcRequestId` | string | Oracle request ID | +| `alarmSuppression` | json | Suppression configuration | +| `etag` | string | Concurrency ETag | + +### OCI Monitoring Delete Alarm Suppression + +Delete one alarm suppression resource + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `oauthCredential` | string | Yes | Reusable OCI API signing-key credential. | +| `region` | string | No | OCI region override; omit to use the credential region. | +| `alarmSuppressionId` | string | Yes | Alarm suppression OCID. | +| `ifMatch` | string | No | ETag returned by a previous read; the mutation succeeds only if it still matches. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `opcRequestId` | string | Oracle request ID | + +### OCI Monitoring Get Suppression History + +Get alarm-wide and dimension-specific suppression history + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `oauthCredential` | string | Yes | Reusable OCI API signing-key credential. | +| `region` | string | No | OCI region override; omit to use the credential region. | +| `alarmId` | string | Yes | Alarm OCID. | +| `dimensions` | json | No | JSON object of dimension names and single string values; required for dimension suppression. | +| `timeSuppressFromGreaterThanOrEqualTo` | string | No | Inclusive RFC3339 lower bound on suppression start time; cannot be in the future. | +| `timeSuppressFromLessThan` | string | No | Exclusive RFC3339 upper bound on suppression start time; cannot be in the future. | +| `limit` | number | No | Maximum results on this page: 1–1000; defaults to 100. | +| `page` | string | No | Opaque nextPage from the previous response. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `opcRequestId` | string | Oracle request ID | +| `suppressionHistory` | array | Effective suppression history | +| ↳ `suppressionId` | string | Suppression OCID | +| ↳ `alarmSuppressionTarget` | json | Suppression target | +| ↳ `targetType` | string | ALARM or COMPARTMENT | +| ↳ `alarmId` | string | Target alarm OCID | +| ↳ `compartmentId` | string | Target compartment OCID | +| ↳ `compartmentIdInSubtree` | boolean | Whether descendants are targeted | +| ↳ `level` | string | Suppression level | +| ↳ `displayName` | string | Display name | +| ↳ `description` | string | Suppression reason | +| ↳ `dimensions` | json | Suppressed dimensions | +| ↳ `timeEffectiveFrom` | string | Effective start | +| ↳ `timeEffectiveUntil` | string | Effective end | +| ↳ `suppressionConditions` | array | Suppression preconditions | +| ↳ `conditionType` | string | Suppression condition type | +| ↳ `suppressionRecurrence` | string | Recurrence expression | +| ↳ `suppressionDuration` | string | Duration of each recurrence | +| `nextPage` | string | Opaque token for the next page; null when complete | + +### OCI Monitoring Remove Alarm Suppression + +Remove the existing suppression attached to an alarm + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `oauthCredential` | string | Yes | Reusable OCI API signing-key credential. | +| `region` | string | No | OCI region override; omit to use the credential region. | +| `alarmId` | string | Yes | Alarm OCID. | +| `ifMatch` | string | No | ETag returned by a previous read; the mutation succeeds only if it still matches. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `opcRequestId` | string | Oracle request ID | + + diff --git a/apps/sim/blocks/blocks/oci_monitoring.ts b/apps/sim/blocks/blocks/oci_monitoring.ts new file mode 100644 index 00000000000..e3c84388c18 --- /dev/null +++ b/apps/sim/blocks/blocks/oci_monitoring.ts @@ -0,0 +1,1459 @@ +import { NetSuiteIcon } from '@/components/icons' +import { getScopesForService } from '@/lib/oauth/utils' +import { AuthMode, type BlockConfig, type BlockMeta, IntegrationType } from '@/blocks/types' +import type { OciMonitoringResponse } from '@/tools/oci_monitoring/types' + +const NAMESPACE_FIELD = ['namespaceSelector', 'namespaceInput'] as const +const ALARM_FIELD = ['alarmIdSelector', 'alarmIdInput'] as const +const SUPPRESSION_FIELD = ['alarmSuppressionIdSelector', 'alarmSuppressionIdInput'] as const + +export const OciMonitoringBlock: BlockConfig = { + type: 'oci_monitoring', + name: 'OCI Monitoring', + description: 'Discover and query metrics, publish custom metrics, and manage OCI alarms', + longDescription: + 'Connect a reusable OCI API signing-key credential to discover metrics, run MQL queries, publish custom metrics, and manage alarms and maintenance suppressions. Reads and histories return one bounded page; queries expose explicit stream and datapoint budgets. Alarm destinations reference existing Notifications topics or Streaming streams. IAM policies must grant the selected operations in the alarm and metric compartments; creating and updating alarms also require metric-read access. Use the separate OCI Logging integration for logs.', + category: 'tools', + integrationType: IntegrationType.Observability, + authMode: AuthMode.ApiKey, + docsLink: 'https://docs.sim.ai/integrations/oci_monitoring', + bgColor: '#FFFFFF', + icon: NetSuiteIcon, + canvasPresentation: { + defaultTitle: 'OCI Monitoring', + sentences: { + byOperation: { + list_metrics: ['List metrics', { text: 'in namespace', field: NAMESPACE_FIELD }], + summarize_metrics_data: [{ text: 'Query metrics in', field: NAMESPACE_FIELD, core: true }], + post_metric_data: ['Publish metrics'], + list_alarms: [{ text: 'List alarms in', field: 'compartmentId', core: true }], + list_alarms_status: ['List alarm statuses', { text: 'matching', field: 'status' }], + get_alarm: [{ text: 'Get alarm', field: ALARM_FIELD, core: true }], + get_alarm_history: [{ text: 'Read history for', field: ALARM_FIELD, core: true }], + retrieve_dimension_states: [ + { text: 'Inspect dimension states for', field: ALARM_FIELD, core: true }, + ], + create_alarm: [{ text: 'Create alarm', field: 'displayName', core: true }], + update_alarm: [{ text: 'Update alarm', field: ALARM_FIELD, core: true }], + delete_alarm: [{ text: 'Delete alarm', field: ALARM_FIELD, core: true }], + create_alarm_suppression: [{ text: 'Suppress alarm', field: ALARM_FIELD, core: true }], + list_alarm_suppressions: [ + { text: 'List suppressions for', field: ALARM_FIELD, core: true }, + ], + get_alarm_suppression: [{ text: 'Get suppression', field: SUPPRESSION_FIELD, core: true }], + delete_alarm_suppression: [ + { text: 'Delete suppression', field: SUPPRESSION_FIELD, core: true }, + ], + summarize_alarm_suppression_history: [ + { text: 'Read suppression history for', field: ALARM_FIELD, core: true }, + ], + remove_alarm_suppression: [ + { text: 'Remove inline suppression from', field: ALARM_FIELD, core: true }, + ], + }, + }, + }, + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'List Metrics', id: 'list_metrics' }, + { label: 'Query Metrics', id: 'summarize_metrics_data' }, + { label: 'Publish Metrics', id: 'post_metric_data' }, + { label: 'List Alarms', id: 'list_alarms' }, + { label: 'List Alarm Statuses', id: 'list_alarms_status' }, + { label: 'Get Alarm', id: 'get_alarm' }, + { label: 'Get Alarm History', id: 'get_alarm_history' }, + { label: 'Get Dimension States', id: 'retrieve_dimension_states' }, + { label: 'Create Alarm', id: 'create_alarm' }, + { label: 'Update Alarm', id: 'update_alarm' }, + { label: 'Delete Alarm', id: 'delete_alarm' }, + { label: 'Create Alarm Suppression', id: 'create_alarm_suppression' }, + { label: 'List Alarm Suppressions', id: 'list_alarm_suppressions' }, + { label: 'Get Alarm Suppression', id: 'get_alarm_suppression' }, + { label: 'Delete Alarm Suppression', id: 'delete_alarm_suppression' }, + { label: 'Get Suppression History', id: 'summarize_alarm_suppression_history' }, + { label: 'Remove Alarm Suppression', id: 'remove_alarm_suppression' }, + ], + value: () => 'list_metrics', + }, + { + id: 'credential', + title: 'OCI Account', + type: 'oauth-input', + serviceId: 'oci_monitoring', + credentialKind: 'service-account', + canonicalParamId: 'oauthCredential', + requiredScopes: getScopesForService('oci_monitoring'), + mode: 'basic', + required: true, + placeholder: 'Select an OCI credential', + }, + { + id: 'manualCredential', + title: 'OCI Account', + type: 'short-input', + canonicalParamId: 'oauthCredential', + mode: 'advanced', + required: true, + placeholder: 'Enter credential ID', + }, + { + id: 'region', + title: 'OCI Region', + type: 'short-input', + placeholder: 'Credential region, or e.g. us-ashburn-1', + }, + { + id: 'compartmentId', + title: 'Compartment ID', + type: 'short-input', + condition: { + field: 'operation', + value: [ + 'list_metrics', + 'summarize_metrics_data', + 'list_alarms', + 'list_alarms_status', + 'create_alarm', + 'update_alarm', + 'get_alarm', + 'get_alarm_history', + 'retrieve_dimension_states', + 'delete_alarm', + 'create_alarm_suppression', + 'list_alarm_suppressions', + 'get_alarm_suppression', + 'delete_alarm_suppression', + 'summarize_alarm_suppression_history', + 'remove_alarm_suppression', + ], + }, + required: { + field: 'operation', + value: [ + 'list_metrics', + 'summarize_metrics_data', + 'list_alarms', + 'list_alarms_status', + 'create_alarm', + ], + }, + placeholder: + 'Compartment OCID. Subtree queries require the tenancy OCID and tenancy-level access.', + }, + { + id: 'namespaceSelector', + title: 'Namespace', + type: 'file-selector', + canonicalParamId: 'namespace', + selectorKey: 'oci_monitoring.namespaces', + dependsOn: { + all: ['oauthCredential'], + any: ['oauthCredential', 'region', 'compartmentId', 'metricCompartmentId'], + }, + mode: 'basic', + condition: { + field: 'operation', + value: ['list_metrics', 'summarize_metrics_data', 'create_alarm', 'update_alarm'], + }, + required: { field: 'operation', value: ['summarize_metrics_data', 'create_alarm'] }, + placeholder: 'Select namespace', + }, + { + id: 'namespaceInput', + title: 'Namespace', + type: 'short-input', + canonicalParamId: 'namespace', + mode: 'advanced', + condition: { + field: 'operation', + value: ['list_metrics', 'summarize_metrics_data', 'create_alarm', 'update_alarm'], + }, + required: { field: 'operation', value: ['summarize_metrics_data', 'create_alarm'] }, + placeholder: 'Enter namespace', + }, + { + id: 'name', + title: 'Metric Name', + type: 'short-input', + condition: { field: 'operation', value: ['list_metrics'] }, + required: false, + placeholder: 'Exact metric name to discover.', + }, + { + id: 'resourceGroup', + title: 'Resource Group', + type: 'short-input', + condition: { + field: 'operation', + value: ['list_metrics', 'summarize_metrics_data', 'create_alarm', 'update_alarm'], + }, + required: false, + mode: 'advanced', + placeholder: 'Optional metric resource group; leave blank to omit this filter.', + }, + { + id: 'dimensionFilters', + title: 'Dimension Filters', + type: 'code', + condition: { field: 'operation', value: ['list_metrics', 'retrieve_dimension_states'] }, + required: false, + mode: 'advanced', + placeholder: + 'JSON object of dimension names and values. Discovery ignores this with groupBy; dimension-state retrieval requires an exact complete set of dimensions.', + wandConfig: { + enabled: true, + prompt: + 'JSON object of dimension names and values. Discovery ignores this with groupBy; dimension-state retrieval requires an exact complete set of dimensions. Return ONLY the JSON object.', + placeholder: 'Describe the desired value', + generationType: 'json-object', + }, + }, + { + id: 'groupBy', + title: 'Group By', + type: 'code', + condition: { field: 'operation', value: ['list_metrics'] }, + required: false, + mode: 'advanced', + placeholder: + 'JSON array of namespace, name, or resourceGroup. When present, dimensionFilters is ignored.', + wandConfig: { + enabled: true, + prompt: + 'JSON array of namespace, name, or resourceGroup. When present, dimensionFilters is ignored. Return ONLY the JSON array.', + placeholder: 'Describe the desired value', + generationType: 'json-array', + }, + }, + { + id: 'sortBy', + title: 'Sort By', + type: 'short-input', + condition: { + field: 'operation', + value: ['list_metrics', 'list_alarms', 'list_alarms_status', 'list_alarm_suppressions'], + }, + required: false, + mode: 'advanced', + placeholder: + 'Metrics: NAMESPACE, NAME, RESOURCEGROUP. Alarms: displayName, severity. Suppressions: displayName, timeCreated, timeSuppressFrom.', + }, + { + id: 'sortOrder', + title: 'Sort Order', + type: 'dropdown', + condition: { + field: 'operation', + value: ['list_metrics', 'list_alarms', 'list_alarms_status', 'list_alarm_suppressions'], + }, + required: false, + mode: 'advanced', + options: [ + { label: 'Use default / keep current', id: '' }, + { label: 'ASC', id: 'ASC' }, + { label: 'DESC', id: 'DESC' }, + ], + }, + { + id: 'compartmentIdInSubtree', + title: 'Compartment Id In Subtree', + type: 'dropdown', + condition: { + field: 'operation', + value: ['list_metrics', 'summarize_metrics_data', 'list_alarms', 'list_alarms_status'], + }, + required: false, + mode: 'advanced', + options: [ + { label: 'Use default / keep current', id: '' }, + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + }, + { + id: 'limit', + title: 'Limit', + type: 'short-input', + condition: { + field: 'operation', + value: [ + 'list_metrics', + 'list_alarms', + 'list_alarms_status', + 'get_alarm_history', + 'retrieve_dimension_states', + 'list_alarm_suppressions', + 'summarize_alarm_suppression_history', + ], + }, + required: false, + mode: 'advanced', + placeholder: 'Maximum results on this page: 1–1000; defaults to 100.', + }, + { + id: 'page', + title: 'Page', + type: 'short-input', + condition: { + field: 'operation', + value: [ + 'list_metrics', + 'list_alarms', + 'list_alarms_status', + 'get_alarm_history', + 'retrieve_dimension_states', + 'list_alarm_suppressions', + 'summarize_alarm_suppression_history', + ], + }, + required: false, + mode: 'advanced', + placeholder: 'Opaque nextPage from the previous response.', + }, + { + id: 'query', + title: 'MQL Query', + type: 'code', + condition: { + field: 'operation', + value: ['summarize_metrics_data', 'create_alarm', 'update_alarm'], + }, + required: { field: 'operation', value: ['summarize_metrics_data', 'create_alarm'] }, + placeholder: + 'Oracle MQL expression, such as CpuUtilization[1m].mean(); alarm expressions also require a threshold or absence condition.', + wandConfig: { + enabled: true, + prompt: + 'Generate an Oracle Monitoring MQL expression. Example: CpuUtilization[1m].mean(); alarms require a threshold such as > 90 or absent(). Preserve dimension values in quoted filters. Return ONLY the MQL expression.', + placeholder: 'Describe the desired value', + }, + }, + { + id: 'startTime', + title: 'Start Time', + type: 'short-input', + condition: { field: 'operation', value: ['summarize_metrics_data'] }, + required: false, + mode: 'advanced', + placeholder: + 'Inclusive RFC3339 query start. Oracle defaults to three hours before the request.', + wandConfig: { + enabled: true, + prompt: + 'Generate a timestamp in RFC3339 format with a timezone, such as 2026-01-01T12:00:00Z. Return ONLY the timestamp.', + placeholder: 'Describe the desired value', + generationType: 'timestamp', + }, + }, + { + id: 'endTime', + title: 'End Time', + type: 'short-input', + condition: { field: 'operation', value: ['summarize_metrics_data'] }, + required: false, + mode: 'advanced', + placeholder: 'Exclusive RFC3339 query end. Oracle defaults to request time.', + wandConfig: { + enabled: true, + prompt: + 'Generate a timestamp in RFC3339 format with a timezone, such as 2026-01-01T12:00:00Z. Return ONLY the timestamp.', + placeholder: 'Describe the desired value', + generationType: 'timestamp', + }, + }, + { + id: 'resolution', + title: 'Resolution', + type: 'short-input', + condition: { + field: 'operation', + value: ['summarize_metrics_data', 'create_alarm', 'update_alarm'], + }, + required: false, + mode: 'advanced', + placeholder: + 'Aggregation window spacing, no greater than the MQL interval. Queries accept 1m–60m, 1h–24h, or 1d; alarms accept only 1m.', + }, + { + id: 'maxStreams', + title: 'Maximum Streams', + type: 'short-input', + condition: { field: 'operation', value: ['summarize_metrics_data'] }, + required: false, + mode: 'advanced', + placeholder: + 'Maximum returned metric streams: defaults to 100, up to 2000. Excess results fail without truncation.', + }, + { + id: 'maxDatapoints', + title: 'Maximum Datapoints', + type: 'short-input', + condition: { field: 'operation', value: ['summarize_metrics_data'] }, + required: false, + mode: 'advanced', + placeholder: + 'Maximum total returned datapoints: defaults to 10000, up to 100000. Excess results fail without truncation.', + }, + { + id: 'metricData', + title: 'Metric Data', + type: 'code', + condition: { field: 'operation', value: ['post_metric_data'] }, + required: { field: 'operation', value: ['post_metric_data'] }, + placeholder: + 'JSON array of custom metric records (compartmentId, namespace, name, dimensions, datapoints). Sim allows 50 records and 1 MiB per request. Timestamps must be less than two hours old and less than ten minutes ahead.', + wandConfig: { + enabled: true, + prompt: + 'JSON array of custom metric records (compartmentId, namespace, name, dimensions, datapoints). Sim allows 50 records and 1 MiB per request. Timestamps must be less than two hours old and less than ten minutes ahead. Return ONLY the JSON array.', + placeholder: 'Describe the desired value', + generationType: 'json-array', + }, + }, + { + id: 'batchAtomicity', + title: 'Batch Atomicity', + type: 'dropdown', + condition: { field: 'operation', value: ['post_metric_data'] }, + required: false, + mode: 'advanced', + options: [ + { label: 'Use default / keep current', id: '' }, + { label: 'ATOMIC', id: 'ATOMIC' }, + { label: 'NON_ATOMIC', id: 'NON_ATOMIC' }, + ], + }, + { + id: 'displayName', + title: 'Display Name', + type: 'short-input', + condition: { + field: 'operation', + value: [ + 'list_alarms', + 'list_alarms_status', + 'create_alarm', + 'update_alarm', + 'create_alarm_suppression', + 'list_alarm_suppressions', + ], + }, + required: { field: 'operation', value: ['create_alarm', 'create_alarm_suppression'] }, + placeholder: 'Alarm or suppression display name; list operations use exact matching.', + }, + { + id: 'lifecycleState', + title: 'Lifecycle State', + type: 'short-input', + condition: { field: 'operation', value: ['list_alarms', 'list_alarm_suppressions'] }, + required: false, + mode: 'advanced', + placeholder: 'ACTIVE or DELETED; alarms also accept DELETING. Omit for ACTIVE.', + }, + { + id: 'resourceId', + title: 'Resource Id', + type: 'short-input', + condition: { field: 'operation', value: ['list_alarms_status'] }, + required: false, + mode: 'advanced', + placeholder: 'Filter alarm status by monitored resource OCID.', + }, + { + id: 'serviceName', + title: 'Service Name', + type: 'short-input', + condition: { field: 'operation', value: ['list_alarms_status'] }, + required: false, + mode: 'advanced', + placeholder: 'Filter by the exact service-name dimension.', + }, + { + id: 'entityId', + title: 'Entity Id', + type: 'short-input', + condition: { field: 'operation', value: ['list_alarms_status'] }, + required: false, + mode: 'advanced', + placeholder: 'Filter by monitored entity OCID.', + }, + { + id: 'status', + title: 'Status', + type: 'dropdown', + condition: { field: 'operation', value: ['list_alarms_status', 'retrieve_dimension_states'] }, + required: false, + mode: 'advanced', + options: [ + { label: 'Use default / keep current', id: '' }, + { label: 'FIRING', id: 'FIRING' }, + { label: 'OK', id: 'OK' }, + ], + }, + { + id: 'alarmIdSelector', + title: 'Alarm', + type: 'file-selector', + canonicalParamId: 'alarmId', + selectorKey: 'oci_monitoring.alarms', + dependsOn: { + all: ['oauthCredential', 'compartmentId'], + any: ['oauthCredential', 'region'], + }, + mode: 'basic', + condition: { + field: 'operation', + value: [ + 'get_alarm', + 'get_alarm_history', + 'retrieve_dimension_states', + 'update_alarm', + 'delete_alarm', + 'create_alarm_suppression', + 'list_alarm_suppressions', + 'summarize_alarm_suppression_history', + 'remove_alarm_suppression', + 'get_alarm_suppression', + 'delete_alarm_suppression', + ], + }, + required: { + field: 'operation', + value: [ + 'get_alarm', + 'get_alarm_history', + 'retrieve_dimension_states', + 'update_alarm', + 'delete_alarm', + 'create_alarm_suppression', + 'list_alarm_suppressions', + 'summarize_alarm_suppression_history', + 'remove_alarm_suppression', + ], + }, + placeholder: 'Select alarm', + }, + { + id: 'alarmIdInput', + title: 'Alarm', + type: 'short-input', + canonicalParamId: 'alarmId', + mode: 'advanced', + condition: { + field: 'operation', + value: [ + 'get_alarm', + 'get_alarm_history', + 'retrieve_dimension_states', + 'update_alarm', + 'delete_alarm', + 'create_alarm_suppression', + 'list_alarm_suppressions', + 'summarize_alarm_suppression_history', + 'remove_alarm_suppression', + 'get_alarm_suppression', + 'delete_alarm_suppression', + ], + }, + required: { + field: 'operation', + value: [ + 'get_alarm', + 'get_alarm_history', + 'retrieve_dimension_states', + 'update_alarm', + 'delete_alarm', + 'create_alarm_suppression', + 'list_alarm_suppressions', + 'summarize_alarm_suppression_history', + 'remove_alarm_suppression', + ], + }, + placeholder: 'Enter alarm', + }, + { + id: 'alarmHistorytype', + title: 'History Type', + type: 'dropdown', + condition: { field: 'operation', value: ['get_alarm_history'] }, + required: false, + mode: 'advanced', + options: [ + { label: 'Use default / keep current', id: '' }, + { label: 'STATE_HISTORY', id: 'STATE_HISTORY' }, + { label: 'STATE_TRANSITION_HISTORY', id: 'STATE_TRANSITION_HISTORY' }, + { label: 'RULE_HISTORY', id: 'RULE_HISTORY' }, + { label: 'RULE_TRANSITION_HISTORY', id: 'RULE_TRANSITION_HISTORY' }, + ], + }, + { + id: 'timestampGreaterThanOrEqualTo', + title: 'Timestamp Greater Than Or Equal To', + type: 'short-input', + condition: { field: 'operation', value: ['get_alarm_history'] }, + required: false, + mode: 'advanced', + placeholder: 'Inclusive RFC3339 lower bound for alarm history.', + wandConfig: { + enabled: true, + prompt: + 'Generate a timestamp in RFC3339 format with a timezone, such as 2026-01-01T12:00:00Z. Return ONLY the timestamp.', + placeholder: 'Describe the desired value', + generationType: 'timestamp', + }, + }, + { + id: 'timestampLessThan', + title: 'Timestamp Less Than', + type: 'short-input', + condition: { field: 'operation', value: ['get_alarm_history'] }, + required: false, + mode: 'advanced', + placeholder: 'Exclusive RFC3339 upper bound for alarm history.', + wandConfig: { + enabled: true, + prompt: + 'Generate a timestamp in RFC3339 format with a timezone, such as 2026-01-01T12:00:00Z. Return ONLY the timestamp.', + placeholder: 'Describe the desired value', + generationType: 'timestamp', + }, + }, + { + id: 'metricCompartmentId', + title: 'Metric Compartment ID', + type: 'short-input', + condition: { field: 'operation', value: ['create_alarm', 'update_alarm'] }, + required: { field: 'operation', value: ['create_alarm'] }, + placeholder: 'Compartment OCID containing the metrics evaluated by the alarm.', + }, + { + id: 'severity', + title: 'Severity', + type: 'dropdown', + condition: { field: 'operation', value: ['create_alarm', 'update_alarm'] }, + required: { field: 'operation', value: ['create_alarm'] }, + options: [ + { label: 'CRITICAL', id: 'CRITICAL' }, + { label: 'ERROR', id: 'ERROR' }, + { label: 'WARNING', id: 'WARNING' }, + { label: 'INFO', id: 'INFO' }, + ], + }, + { + id: 'destinations', + title: 'Destinations', + type: 'code', + condition: { field: 'operation', value: ['create_alarm', 'update_alarm'] }, + required: { field: 'operation', value: ['create_alarm'] }, + placeholder: + 'JSON array of existing Notifications topic or Streaming stream OCIDs; at most one per destination service.', + wandConfig: { + enabled: true, + prompt: + 'JSON array of existing Notifications topic or Streaming stream OCIDs; at most one per destination service. Return ONLY the JSON array.', + placeholder: 'Describe the desired value', + generationType: 'json-array', + }, + }, + { + id: 'isEnabled', + title: 'Alarm Enabled', + type: 'dropdown', + condition: { field: 'operation', value: ['create_alarm', 'update_alarm'] }, + required: { field: 'operation', value: ['create_alarm'] }, + options: [ + { label: 'Use default / keep current', id: '' }, + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + }, + { + id: 'metricCompartmentIdInSubtree', + title: 'Metric Compartment Id In Subtree', + type: 'dropdown', + condition: { field: 'operation', value: ['create_alarm', 'update_alarm'] }, + required: false, + mode: 'advanced', + options: [ + { label: 'Use default / keep current', id: '' }, + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + }, + { + id: 'pendingDuration', + title: 'Pending Duration', + type: 'short-input', + condition: { field: 'operation', value: ['create_alarm', 'update_alarm'] }, + required: false, + mode: 'advanced', + placeholder: 'ISO 8601 duration before firing, from PT1M to PT1H.', + }, + { + id: 'body', + title: 'Body', + type: 'long-input', + condition: { field: 'operation', value: ['create_alarm', 'update_alarm'] }, + required: false, + mode: 'advanced', + placeholder: 'Alarm notification message body.', + }, + { + id: 'isNotificationsPerMetricDimensionEnabled', + title: 'Is Notifications Per Metric Dimension Enabled', + type: 'dropdown', + condition: { field: 'operation', value: ['create_alarm', 'update_alarm'] }, + required: false, + mode: 'advanced', + options: [ + { label: 'Use default / keep current', id: '' }, + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + }, + { + id: 'messageFormat', + title: 'Message Format', + type: 'dropdown', + condition: { field: 'operation', value: ['create_alarm', 'update_alarm'] }, + required: false, + mode: 'advanced', + options: [ + { label: 'Use default / keep current', id: '' }, + { label: 'RAW', id: 'RAW' }, + { label: 'PRETTY_JSON', id: 'PRETTY_JSON' }, + { label: 'ONS_OPTIMIZED', id: 'ONS_OPTIMIZED' }, + ], + }, + { + id: 'repeatNotificationDuration', + title: 'Repeat Notification Duration', + type: 'short-input', + condition: { field: 'operation', value: ['create_alarm', 'update_alarm'] }, + required: false, + mode: 'advanced', + placeholder: 'ISO 8601 interval for repeating notifications.', + }, + { + id: 'suppression', + title: 'Suppression', + type: 'code', + condition: { field: 'operation', value: ['create_alarm', 'update_alarm'] }, + required: false, + mode: 'advanced', + placeholder: + 'JSON object with timeSuppressFrom, timeSuppressUntil, and optional description. Use Remove Alarm Suppression to clear it.', + wandConfig: { + enabled: true, + prompt: + 'JSON object with timeSuppressFrom, timeSuppressUntil, and optional description. Use Remove Alarm Suppression to clear it. Return ONLY the JSON object.', + placeholder: 'Describe the desired value', + generationType: 'json-object', + }, + }, + { + id: 'freeformTags', + title: 'Freeform Tags', + type: 'code', + condition: { + field: 'operation', + value: ['create_alarm', 'update_alarm', 'create_alarm_suppression'], + }, + required: false, + mode: 'advanced', + placeholder: 'JSON object of string tag names and values.', + wandConfig: { + enabled: true, + prompt: 'JSON object of string tag names and values. Return ONLY the JSON object.', + placeholder: 'Describe the desired value', + generationType: 'json-object', + }, + }, + { + id: 'definedTags', + title: 'Defined Tags', + type: 'code', + condition: { + field: 'operation', + value: ['create_alarm', 'update_alarm', 'create_alarm_suppression'], + }, + required: false, + mode: 'advanced', + placeholder: 'JSON object of tag namespaces and values.', + wandConfig: { + enabled: true, + prompt: 'JSON object of tag namespaces and values. Return ONLY the JSON object.', + placeholder: 'Describe the desired value', + generationType: 'json-object', + }, + }, + { + id: 'overrides', + title: 'Overrides', + type: 'code', + condition: { field: 'operation', value: ['create_alarm', 'update_alarm'] }, + required: false, + mode: 'advanced', + placeholder: 'JSON array of up to two alarm trigger-rule overrides.', + wandConfig: { + enabled: true, + prompt: 'JSON array of up to two alarm trigger-rule overrides. Return ONLY the JSON array.', + placeholder: 'Describe the desired value', + generationType: 'json-array', + }, + }, + { + id: 'ruleName', + title: 'Rule Name', + type: 'short-input', + condition: { field: 'operation', value: ['create_alarm', 'update_alarm'] }, + required: false, + mode: 'advanced', + placeholder: 'Name of the base alarm rule.', + }, + { + id: 'notificationVersion', + title: 'Notification Version', + type: 'short-input', + condition: { field: 'operation', value: ['create_alarm', 'update_alarm'] }, + required: false, + mode: 'advanced', + placeholder: 'Oracle alarm notification version.', + }, + { + id: 'notificationTitle', + title: 'Notification Title', + type: 'short-input', + condition: { field: 'operation', value: ['create_alarm', 'update_alarm'] }, + required: false, + mode: 'advanced', + placeholder: 'Custom title for alarm notifications.', + }, + { + id: 'evaluationSlackDuration', + title: 'Evaluation Slack Duration', + type: 'short-input', + condition: { field: 'operation', value: ['create_alarm', 'update_alarm'] }, + required: false, + mode: 'advanced', + placeholder: 'ISO 8601 ingestion delay before evaluation, from PT3M to PT2H.', + }, + { + id: 'alarmSummary', + title: 'Alarm Summary', + type: 'short-input', + condition: { field: 'operation', value: ['create_alarm', 'update_alarm'] }, + required: false, + mode: 'advanced', + placeholder: 'Custom alarm summary.', + }, + { + id: 'opcRetryToken', + title: 'Retry Token', + type: 'short-input', + condition: { field: 'operation', value: ['create_alarm', 'create_alarm_suppression'] }, + required: false, + mode: 'advanced', + placeholder: + 'Stable retry token for creation. Enables bounded tokenized retries; reuse it for the same logical creation.', + }, + { + id: 'ifMatch', + title: 'If-Match ETag', + type: 'short-input', + condition: { + field: 'operation', + value: [ + 'update_alarm', + 'delete_alarm', + 'delete_alarm_suppression', + 'remove_alarm_suppression', + ], + }, + required: false, + mode: 'advanced', + placeholder: + 'ETag returned by a previous read; the mutation succeeds only if it still matches.', + }, + { + id: 'description', + title: 'Description', + type: 'long-input', + condition: { field: 'operation', value: ['create_alarm_suppression'] }, + required: false, + mode: 'advanced', + placeholder: 'Reason for the suppression.', + }, + { + id: 'level', + title: 'Level', + type: 'dropdown', + condition: { + field: 'operation', + value: ['create_alarm_suppression', 'list_alarm_suppressions'], + }, + required: false, + options: [ + { label: 'ALARM on create / any level on list', id: '' }, + { label: 'ALARM', id: 'ALARM' }, + { label: 'DIMENSION', id: 'DIMENSION' }, + ], + }, + { + id: 'dimensions', + title: 'Dimensions', + type: 'code', + condition: { + field: 'operation', + value: ['create_alarm_suppression', 'summarize_alarm_suppression_history'], + }, + required: { + field: 'operation', + value: 'create_alarm_suppression', + and: { field: 'level', value: 'DIMENSION' }, + }, + placeholder: + 'JSON object of dimension names and single string values; required for dimension suppression.', + wandConfig: { + enabled: true, + prompt: + 'JSON object of dimension names and single string values; required for dimension suppression. Return ONLY the JSON object.', + placeholder: 'Describe the desired value', + generationType: 'json-object', + }, + }, + { + id: 'timeSuppressFrom', + title: 'Time Suppress From', + type: 'short-input', + condition: { field: 'operation', value: ['create_alarm_suppression'] }, + required: { field: 'operation', value: ['create_alarm_suppression'] }, + placeholder: 'Inclusive RFC3339 start of suppression.', + wandConfig: { + enabled: true, + prompt: + 'Generate a timestamp in RFC3339 format with a timezone, such as 2026-01-01T12:00:00Z. Return ONLY the timestamp.', + placeholder: 'Describe the desired value', + generationType: 'timestamp', + }, + }, + { + id: 'timeSuppressUntil', + title: 'Time Suppress Until', + type: 'short-input', + condition: { field: 'operation', value: ['create_alarm_suppression'] }, + required: { field: 'operation', value: ['create_alarm_suppression'] }, + placeholder: 'Inclusive RFC3339 end of suppression.', + wandConfig: { + enabled: true, + prompt: + 'Generate a timestamp in RFC3339 format with a timezone, such as 2026-01-01T12:00:00Z. Return ONLY the timestamp.', + placeholder: 'Describe the desired value', + generationType: 'timestamp', + }, + }, + { + id: 'isAllSuppressions', + title: 'Is All Suppressions', + type: 'dropdown', + condition: { field: 'operation', value: ['list_alarm_suppressions'] }, + required: false, + mode: 'advanced', + options: [ + { label: 'Use default / keep current', id: '' }, + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + }, + { + id: 'alarmSuppressionIdSelector', + title: 'Alarm Suppression', + type: 'file-selector', + canonicalParamId: 'alarmSuppressionId', + selectorKey: 'oci_monitoring.alarmSuppressions', + dependsOn: { + all: ['oauthCredential', 'alarmId'], + any: ['oauthCredential', 'region'], + }, + mode: 'basic', + condition: { + field: 'operation', + value: ['get_alarm_suppression', 'delete_alarm_suppression'], + }, + required: { + field: 'operation', + value: ['get_alarm_suppression', 'delete_alarm_suppression'], + }, + placeholder: 'Select alarm suppression', + }, + { + id: 'alarmSuppressionIdInput', + title: 'Alarm Suppression', + type: 'short-input', + canonicalParamId: 'alarmSuppressionId', + mode: 'advanced', + condition: { + field: 'operation', + value: ['get_alarm_suppression', 'delete_alarm_suppression'], + }, + required: { + field: 'operation', + value: ['get_alarm_suppression', 'delete_alarm_suppression'], + }, + placeholder: 'Enter alarm suppression', + }, + { + id: 'timeSuppressFromGreaterThanOrEqualTo', + title: 'Time Suppress From Greater Than Or Equal To', + type: 'short-input', + condition: { field: 'operation', value: ['summarize_alarm_suppression_history'] }, + required: false, + mode: 'advanced', + placeholder: + 'Inclusive RFC3339 lower bound on suppression start time; cannot be in the future.', + wandConfig: { + enabled: true, + prompt: + 'Generate a timestamp in RFC3339 format with a timezone, such as 2026-01-01T12:00:00Z. Return ONLY the timestamp.', + placeholder: 'Describe the desired value', + generationType: 'timestamp', + }, + }, + { + id: 'timeSuppressFromLessThan', + title: 'Time Suppress From Less Than', + type: 'short-input', + condition: { field: 'operation', value: ['summarize_alarm_suppression_history'] }, + required: false, + mode: 'advanced', + placeholder: + 'Exclusive RFC3339 upper bound on suppression start time; cannot be in the future.', + wandConfig: { + enabled: true, + prompt: + 'Generate a timestamp in RFC3339 format with a timezone, such as 2026-01-01T12:00:00Z. Return ONLY the timestamp.', + placeholder: 'Describe the desired value', + generationType: 'timestamp', + }, + }, + ], + tools: { + access: [ + 'oci_monitoring_list_metrics', + 'oci_monitoring_summarize_metrics_data', + 'oci_monitoring_post_metric_data', + 'oci_monitoring_list_alarms', + 'oci_monitoring_list_alarms_status', + 'oci_monitoring_get_alarm', + 'oci_monitoring_get_alarm_history', + 'oci_monitoring_retrieve_dimension_states', + 'oci_monitoring_create_alarm', + 'oci_monitoring_update_alarm', + 'oci_monitoring_delete_alarm', + 'oci_monitoring_create_alarm_suppression', + 'oci_monitoring_list_alarm_suppressions', + 'oci_monitoring_get_alarm_suppression', + 'oci_monitoring_delete_alarm_suppression', + 'oci_monitoring_summarize_alarm_suppression_history', + 'oci_monitoring_remove_alarm_suppression', + ], + config: { + tool: (params) => `oci_monitoring_${params.operation}`, + params: (params) => { + const { operation, ...result } = params + for (const key of ['limit', 'maxStreams', 'maxDatapoints']) { + if (typeof result[key] === 'string' && result[key].trim() !== '') { + result[key] = Number(result[key]) + } + } + for (const key of [ + 'compartmentIdInSubtree', + 'metricCompartmentIdInSubtree', + 'isEnabled', + 'isNotificationsPerMetricDimensionEnabled', + 'isAllSuppressions', + ]) { + if (result[key] === 'true') result[key] = true + else if (result[key] === 'false') result[key] = false + } + return Object.fromEntries( + Object.entries(result).map(([key, value]) => [ + key, + value === '' || value === null ? undefined : value, + ]) + ) + }, + }, + }, + inputs: { + oauthCredential: { type: 'string', description: 'Reusable OCI credential ID' }, + region: { type: 'string', description: 'Optional OCI region override' }, + compartmentId: { + type: 'string', + description: + 'Compartment OCID. Subtree queries require the tenancy OCID and tenancy-level access.', + }, + namespace: { type: 'string', description: 'Metric namespace, such as oci_computeagent.' }, + name: { type: 'string', description: 'Exact metric name to discover.' }, + resourceGroup: { + type: 'string', + description: 'Optional metric resource group; leave blank to omit this filter.', + }, + dimensionFilters: { + type: 'json', + description: + 'JSON object of dimension names and values. Discovery ignores this with groupBy; dimension-state retrieval requires an exact complete set of dimensions.', + }, + groupBy: { + type: 'json', + description: + 'JSON array of namespace, name, or resourceGroup. When present, dimensionFilters is ignored.', + }, + sortBy: { + type: 'string', + description: + 'Metrics: NAMESPACE, NAME, RESOURCEGROUP. Alarms: displayName, severity. Suppressions: displayName, timeCreated, timeSuppressFrom.', + }, + sortOrder: { type: 'string', description: 'Sort direction: ASC or DESC.' }, + compartmentIdInSubtree: { + type: 'boolean', + description: + 'Include child compartments; requires the tenancy OCID and tenancy-level permissions.', + }, + limit: { + type: 'number', + description: 'Maximum results on this page: 1–1000; defaults to 100.', + }, + page: { type: 'string', description: 'Opaque nextPage from the previous response.' }, + query: { + type: 'string', + description: + 'Oracle MQL expression, such as CpuUtilization[1m].mean(); alarm expressions also require a threshold or absence condition.', + }, + startTime: { + type: 'string', + description: + 'Inclusive RFC3339 query start. Oracle defaults to three hours before the request.', + }, + endTime: { + type: 'string', + description: 'Exclusive RFC3339 query end. Oracle defaults to request time.', + }, + resolution: { + type: 'string', + description: + 'Aggregation window spacing, no greater than the MQL interval. Queries accept 1m–60m, 1h–24h, or 1d; alarms accept only 1m.', + }, + maxStreams: { + type: 'number', + description: + 'Maximum returned metric streams: defaults to 100, up to 2000. Excess results fail without truncation.', + }, + maxDatapoints: { + type: 'number', + description: + 'Maximum total returned datapoints: defaults to 10000, up to 100000. Excess results fail without truncation.', + }, + metricData: { + type: 'json', + description: + 'JSON array of custom metric records (compartmentId, namespace, name, dimensions, datapoints). Sim allows 50 records and 1 MiB per request. Timestamps must be less than two hours old and less than ten minutes ahead.', + }, + batchAtomicity: { + type: 'string', + description: + 'ATOMIC rejects the whole batch on validation failure; NON_ATOMIC permits partial acceptance.', + }, + displayName: { + type: 'string', + description: 'Alarm or suppression display name; list operations use exact matching.', + }, + lifecycleState: { + type: 'string', + description: 'ACTIVE or DELETED; alarms also accept DELETING. Omit for ACTIVE.', + }, + resourceId: { type: 'string', description: 'Filter alarm status by monitored resource OCID.' }, + serviceName: { type: 'string', description: 'Filter by the exact service-name dimension.' }, + entityId: { type: 'string', description: 'Filter by monitored entity OCID.' }, + status: { type: 'string', description: 'Alarm status: FIRING or OK.' }, + alarmId: { type: 'string', description: 'Alarm OCID.' }, + alarmHistorytype: { + type: 'string', + description: + 'STATE_HISTORY, STATE_TRANSITION_HISTORY, RULE_HISTORY, or RULE_TRANSITION_HISTORY.', + }, + timestampGreaterThanOrEqualTo: { + type: 'string', + description: 'Inclusive RFC3339 lower bound for alarm history.', + }, + timestampLessThan: { + type: 'string', + description: 'Exclusive RFC3339 upper bound for alarm history.', + }, + metricCompartmentId: { + type: 'string', + description: 'Compartment OCID containing the metrics evaluated by the alarm.', + }, + severity: { type: 'string', description: 'Alarm severity: CRITICAL, ERROR, WARNING, or INFO.' }, + destinations: { + type: 'json', + description: + 'JSON array of existing Notifications topic or Streaming stream OCIDs; at most one per destination service.', + }, + isEnabled: { + type: 'boolean', + description: + 'Whether the alarm evaluates its MQL condition. Omit on update to preserve the existing value.', + }, + metricCompartmentIdInSubtree: { + type: 'boolean', + description: 'Evaluate metrics in child compartments; requires tenancy-level metric access.', + }, + pendingDuration: { + type: 'string', + description: 'ISO 8601 duration before firing, from PT1M to PT1H.', + }, + body: { type: 'string', description: 'Alarm notification message body.' }, + isNotificationsPerMetricDimensionEnabled: { + type: 'boolean', + description: + 'Send separate notifications per metric stream; required by Oracle for dimension-specific suppression.', + }, + messageFormat: { + type: 'string', + description: 'Notification format: RAW, PRETTY_JSON, or ONS_OPTIMIZED.', + }, + repeatNotificationDuration: { + type: 'string', + description: 'ISO 8601 interval for repeating notifications.', + }, + suppression: { + type: 'json', + description: + 'JSON object with timeSuppressFrom, timeSuppressUntil, and optional description. Use Remove Alarm Suppression to clear it.', + }, + freeformTags: { type: 'json', description: 'JSON object of string tag names and values.' }, + definedTags: { type: 'json', description: 'JSON object of tag namespaces and values.' }, + overrides: { + type: 'json', + description: 'JSON array of up to two alarm trigger-rule overrides.', + }, + ruleName: { type: 'string', description: 'Name of the base alarm rule.' }, + notificationVersion: { type: 'string', description: 'Oracle alarm notification version.' }, + notificationTitle: { type: 'string', description: 'Custom title for alarm notifications.' }, + evaluationSlackDuration: { + type: 'string', + description: 'ISO 8601 ingestion delay before evaluation, from PT3M to PT2H.', + }, + alarmSummary: { type: 'string', description: 'Custom alarm summary.' }, + opcRetryToken: { + type: 'string', + description: + 'Stable retry token for creation. Enables bounded tokenized retries; reuse it for the same logical creation.', + }, + ifMatch: { + type: 'string', + description: + 'ETag returned by a previous read; the mutation succeeds only if it still matches.', + }, + description: { type: 'string', description: 'Reason for the suppression.' }, + level: { + type: 'string', + description: + 'ALARM for the whole alarm or DIMENSION for specific metric streams. Sim defaults new suppressions to ALARM.', + }, + dimensions: { + type: 'json', + description: + 'JSON object of dimension names and single string values; required for dimension suppression.', + }, + timeSuppressFrom: { type: 'string', description: 'Inclusive RFC3339 start of suppression.' }, + timeSuppressUntil: { type: 'string', description: 'Inclusive RFC3339 end of suppression.' }, + isAllSuppressions: { + type: 'boolean', + description: 'Include compartment or tenancy suppressions affecting the selected alarm.', + }, + alarmSuppressionId: { type: 'string', description: 'Alarm suppression OCID.' }, + timeSuppressFromGreaterThanOrEqualTo: { + type: 'string', + description: + 'Inclusive RFC3339 lower bound on suppression start time; cannot be in the future.', + }, + timeSuppressFromLessThan: { + type: 'string', + description: + 'Exclusive RFC3339 upper bound on suppression start time; cannot be in the future.', + }, + }, + outputs: { + opcRequestId: { type: 'string', description: 'Oracle request ID' }, + metrics: { + type: 'json', + description: + 'Discovered metric definitions; grouping can omit non-grouped fields (name, namespace, resourceGroup, compartmentId, dimensions)', + }, + nextPage: { type: 'string', description: 'Opaque token for the next page; null when complete' }, + metricData: { + type: 'json', + description: + 'Aggregated metric time series (name, namespace, resourceGroup, compartmentId, dimensions, metadata, resolution, aggregatedDatapoints)', + }, + alarms: { + type: 'json', + description: + 'Alarm summaries (id, displayName, compartmentId, metricCompartmentId, namespace, resourceGroup, query, severity, isEnabled, lifecycleState, destinations, suppression, ruleName, notificationVersion, notificationTitle, evaluationSlackDuration, alarmSummary, isNotificationsPerMetricDimensionEnabled, freeformTags, definedTags, overrides)', + }, + alarm: { + type: 'json', + description: + 'Alarm configuration (id, displayName, compartmentId, metricCompartmentId, namespace, query, severity, destinations, isEnabled, suppression, overrides, lifecycleState, timeCreated, timeUpdated)', + }, + etag: { type: 'string', description: 'Concurrency ETag' }, + alarmStatuses: { + type: 'json', + description: + 'Aggregate alarm statuses (id, displayName, severity, ruleName, timestampTriggered, alarmSummary, status, suppression)', + }, + alarmId: { type: 'string', description: 'Alarm OCID' }, + isEnabled: { type: 'boolean', description: 'Whether the alarm is enabled' }, + history: { + type: 'json', + description: 'Alarm history entries (alarmSummary, summary, timestamp, timestampTriggered)', + }, + isNotificationsPerMetricDimensionEnabled: { + type: 'boolean', + description: 'Whether notifications are split', + }, + dimensionStates: { + type: 'json', + description: + 'Individual metric stream states (alarmSummary, dimensions, status, ruleName, timestamp)', + }, + alarmSuppression: { + type: 'json', + description: + 'Suppression configuration (id, compartmentId, alarmSuppressionTarget, level, displayName, dimensions, timeSuppressFrom, timeSuppressUntil, lifecycleState, suppressionConditions)', + }, + alarmSuppressions: { + type: 'json', + description: + 'Suppression resources (id, compartmentId, alarmSuppressionTarget, level, displayName, description, dimensions, timeSuppressFrom, timeSuppressUntil, lifecycleState, timeCreated, timeUpdated, suppressionConditions, freeformTags, definedTags)', + }, + suppressionHistory: { + type: 'json', + description: + 'Effective suppression history (suppressionId, alarmSuppressionTarget, level, displayName, description, dimensions, timeEffectiveFrom, timeEffectiveUntil, suppressionConditions)', + }, + failedMetricsCount: { type: 'number', description: 'Number of rejected metric records' }, + failedMetrics: { + type: 'json', + description: 'Rejected metric records and their validation errors (message, metricData)', + }, + }, +} + +export const OciMonitoringBlockMeta = { + tags: ['monitoring', 'cloud', 'incident-management'], + url: 'https://www.oracle.com/cloud/observability-and-management/monitoring/', + templates: [ + { + icon: NetSuiteIcon, + title: 'Explore available metrics', + prompt: + 'Create a workflow that lists OCI metric namespaces and definitions in a chosen compartment, then summarizes the available dimensions before selecting an MQL query.', + modules: ['workflows', 'agent'], + category: 'operations', + tags: ['devops', 'monitoring'], + }, + { + icon: NetSuiteIcon, + title: 'Report infrastructure trends', + prompt: + 'Create a scheduled workflow that queries selected OCI CPU and memory metrics over a bounded time window and writes a trend summary to a table.', + modules: ['workflows', 'scheduled', 'tables', 'agent'], + category: 'operations', + tags: ['devops', 'monitoring'], + }, + { + icon: NetSuiteIcon, + title: 'Publish application metrics', + prompt: + 'Create a workflow that publishes a bounded batch of custom OCI application metrics, inspects failed records, and queries the accepted metrics after ingestion.', + modules: ['workflows', 'agent'], + category: 'operations', + tags: ['devops', 'monitoring'], + }, + { + icon: NetSuiteIcon, + title: 'Triage firing alarms', + prompt: + 'Create a scheduled workflow that lists firing OCI alarms, gets their definitions, and reads recent history to prioritize investigation.', + modules: ['workflows', 'scheduled', 'agent'], + category: 'operations', + tags: ['devops', 'monitoring'], + }, + { + icon: NetSuiteIcon, + title: 'Create a threshold alarm', + prompt: + 'Create an OCI alarm from a chosen metric and threshold, using an existing notification destination, then retrieve the alarm configuration.', + modules: ['workflows', 'agent'], + category: 'operations', + tags: ['devops', 'monitoring'], + }, + { + icon: NetSuiteIcon, + title: 'Suppress maintenance alerts', + prompt: + 'Create a workflow that schedules a suppression for a chosen OCI alarm during a maintenance window and confirms its scope and timestamps.', + modules: ['workflows', 'agent'], + category: 'operations', + tags: ['devops', 'monitoring'], + }, + { + icon: NetSuiteIcon, + title: 'Audit dimension suppressions', + prompt: + 'Create a workflow that inspects OCI alarm dimension states and suppression history for a selected resource to explain its notification gaps.', + modules: ['workflows', 'agent'], + category: 'operations', + tags: ['devops', 'monitoring'], + }, + ], + skills: [ + { + name: 'discover-metrics', + description: 'Discover metric namespaces and dimensions', + content: + '# Discover metric namespaces and dimensions\n\n## Steps\n1. Select an OCI credential, region, and compartment.\n2. List metric definitions with namespace grouping.\n3. Inspect a namespace without grouping to see metric dimensions.\n\n## Output\nA bounded inventory of available metrics and dimensions.', + }, + { + name: 'analyze-metric-trends', + description: 'Analyze a bounded MQL time series', + content: + '# Analyze a bounded MQL time series\n\n## Steps\n1. Choose a namespace, metric, and time window.\n2. Write an MQL aggregation and select its resolution.\n3. Query metrics within explicit output budgets and summarize their trend.\n\n## Output\nA trend summary retaining metric dimensions and timestamps.', + }, + { + name: 'triage-firing-alarms', + description: 'Investigate firing alarms and recent transitions', + content: + '# Investigate firing alarms and recent transitions\n\n## Steps\n1. List alarm statuses filtered to FIRING.\n2. Get the alarm definition and recent history.\n3. Inspect dimension states to identify affected metric streams.\n\n## Output\nA prioritized list of affected streams and alarm transitions.', + }, + { + name: 'suppress-maintenance-dimensions', + description: 'Suppress selected metric streams during maintenance', + content: + '# Suppress selected metric streams during maintenance\n\n## Steps\n1. Select an alarm and verify that split notifications are enabled.\n2. Confirm the resource dimensions and maintenance window.\n3. Create a DIMENSION suppression and retrieve it to verify the scope.\n\n## Output\nThe suppression ID, target dimensions, and inclusive start and end times.', + }, + { + name: 'audit-suppression-history', + description: 'Explain suppression periods for an alarm', + content: + '# Explain suppression periods for an alarm\n\n## Steps\n1. Select the alarm and an investigation window.\n2. Get suppression history using the documented time filters.\n3. Compare the effective suppression periods with alarm history.\n\n## Output\nA timeline of suppression periods and affected dimensions.', + }, + ], +} as const satisfies BlockMeta diff --git a/apps/sim/blocks/registry-maps.ts b/apps/sim/blocks/registry-maps.ts index 672ebb5e0e1..098b335e109 100644 --- a/apps/sim/blocks/registry-maps.ts +++ b/apps/sim/blocks/registry-maps.ts @@ -249,6 +249,7 @@ import { NotionV2BlockMeta, } from '@/blocks/blocks/notion' import { ObsidianBlock, ObsidianBlockMeta } from '@/blocks/blocks/obsidian' +import { OciMonitoringBlock, OciMonitoringBlockMeta } from '@/blocks/blocks/oci_monitoring' import { OktaBlock, OktaBlockMeta } from '@/blocks/blocks/okta' import { OneDriveBlock, OneDriveBlockMeta } from '@/blocks/blocks/onedrive' import { OnePasswordBlock, OnePasswordBlockMeta } from '@/blocks/blocks/onepassword' @@ -595,6 +596,7 @@ export const BLOCK_REGISTRY: Record = { notion: NotionBlock, notion_v2: NotionV2Block, obsidian: ObsidianBlock, + oci_monitoring: OciMonitoringBlock, okta: OktaBlock, onedrive: OneDriveBlock, onepassword: OnePasswordBlock, @@ -920,6 +922,7 @@ export const BLOCK_META_REGISTRY: Record = { notion: NotionBlockMeta, notion_v2: NotionV2BlockMeta, obsidian: ObsidianBlockMeta, + oci_monitoring: OciMonitoringBlockMeta, okta: OktaBlockMeta, onedrive: OneDriveBlockMeta, onepassword: OnePasswordBlockMeta, diff --git a/apps/sim/lib/copilot/generated/docs-manifest.ts b/apps/sim/lib/copilot/generated/docs-manifest.ts index 86005762478..a09674bb2e2 100644 --- a/apps/sim/lib/copilot/generated/docs-manifest.ts +++ b/apps/sim/lib/copilot/generated/docs-manifest.ts @@ -243,6 +243,7 @@ export const DOCS_MANIFEST: readonly string[] = [ 'integrations/notion-service-account.mdx', 'integrations/notion.mdx', 'integrations/obsidian.mdx', + 'integrations/oci_monitoring.mdx', 'integrations/okta.mdx', 'integrations/onedrive.mdx', 'integrations/onepassword.mdx', diff --git a/apps/sim/lib/integrations/icon-mapping.ts b/apps/sim/lib/integrations/icon-mapping.ts index a9318bcd494..bcf1703bddd 100644 --- a/apps/sim/lib/integrations/icon-mapping.ts +++ b/apps/sim/lib/integrations/icon-mapping.ts @@ -465,6 +465,7 @@ export const blockTypeToIconMap: Record = { notion: NotionIcon, notion_v2: NotionIcon, obsidian: ObsidianIcon, + oci_monitoring: NetSuiteIcon, okta: OktaIcon, onedrive: MicrosoftOneDriveIcon, onepassword: OnePasswordIcon, diff --git a/apps/sim/lib/internal/oci-monitoring/execute-tool.test.ts b/apps/sim/lib/internal/oci-monitoring/execute-tool.test.ts new file mode 100644 index 00000000000..646f9cbff88 --- /dev/null +++ b/apps/sim/lib/internal/oci-monitoring/execute-tool.test.ts @@ -0,0 +1,270 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + authorize: vi.fn(), + createClient: vi.fn(), + execute: vi.fn(), +})) +vi.mock('@/lib/auth/credential-access', () => ({ + authorizeCredentialUseForAuth: mocks.authorize, +})) +vi.mock('@/lib/internal/oci/client.server', () => ({ createOciClient: mocks.createClient })) +vi.mock('@/lib/internal/oci-monitoring/operations', () => ({ + executeOciMonitoringOperation: mocks.execute, + OciMonitoringInputError: class OciMonitoringInputError extends Error {}, +})) + +import { OciClientError } from '@/lib/internal/oci/errors' +import { executeOciMonitoringTool } from '@/lib/internal/oci-monitoring/execute-tool' +import type { InternalToolOperationCall } from '@/lib/internal/tool-operations/types' + +const connection = { oauthCredential: 'requested-credential', region: 'us-ashburn-1' } +const alarm = { + compartmentId: 'compartment', + metricCompartmentId: 'metrics-compartment', + displayName: 'CPU', + namespace: 'oci_computeagent', + query: 'CpuUtilization[1m].mean() > 80', + severity: 'WARNING', + destinations: ['topic'], + isEnabled: false, +} +const metric = { + compartmentId: 'compartment', + namespace: 'my_app', + name: 'Requests', + dimensions: { resourceId: 'instance' }, + datapoints: [{ timestamp: '2026-09-05T12:00:00Z', value: 0 }], +} +const cases = [ + ['list_metrics', { compartmentId: 'compartment' }], + [ + 'summarize_metrics_data', + { + compartmentId: 'compartment', + namespace: 'my_app', + query: 'Requests[1m].mean()', + }, + ], + ['post_metric_data', { metricData: [metric] }], + ['list_alarms', { compartmentId: 'compartment' }], + ['list_alarms_status', { compartmentId: 'compartment' }], + ['get_alarm', { alarmId: 'alarm' }], + ['get_alarm_history', { alarmId: 'alarm' }], + ['retrieve_dimension_states', { alarmId: 'alarm' }], + ['create_alarm', alarm], + ['update_alarm', { alarmId: 'alarm', isEnabled: false }], + ['delete_alarm', { alarmId: 'alarm' }], + [ + 'create_alarm_suppression', + { + alarmId: 'alarm', + displayName: 'Maintenance', + timeSuppressFrom: '2026-09-05T12:00:00Z', + timeSuppressUntil: '2026-09-05T13:00:00Z', + }, + ], + ['list_alarm_suppressions', { alarmId: 'alarm' }], + ['get_alarm_suppression', { alarmSuppressionId: 'suppression' }], + ['delete_alarm_suppression', { alarmSuppressionId: 'suppression' }], + ['summarize_alarm_suppression_history', { alarmId: 'alarm' }], + ['remove_alarm_suppression', { alarmId: 'alarm' }], +] as const +const client = { bound: true } + +function request(overrides: Partial = {}): InternalToolOperationCall { + return { + toolId: 'oci_monitoring_list_metrics', + input: { ...connection, compartmentId: 'compartment' }, + headers: new Headers(), + context: { + userId: 'trusted-user', + workspaceId: 'trusted-workspace', + workflowId: 'trusted-workflow', + }, + requestId: 'request', + ...overrides, + } +} + +describe('OCI Monitoring internal dispatcher', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.authorize.mockResolvedValue({ + ok: true, + resolvedCredentialId: 'authorized-credential', + credentialType: 'service_account', + }) + mocks.createClient.mockResolvedValue(client) + mocks.execute.mockResolvedValue({ success: true, output: { opcRequestId: 'oracle-request' } }) + }) + + it.each(cases)('authorizes and dispatches %s with cancellation', async (operation, input) => { + const controller = new AbortController() + const response = await executeOciMonitoringTool( + request({ + toolId: `oci_monitoring_${operation}`, + input: { ...connection, ...input }, + signal: controller.signal, + }) + ) + + expect(response.status).toBe(200) + expect(mocks.execute).toHaveBeenCalledWith( + client, + operation, + expect.objectContaining(input), + controller.signal + ) + expect(mocks.createClient).toHaveBeenCalledWith({ + credentialId: 'authorized-credential', + workspaceId: 'trusted-workspace', + serviceId: 'oci_monitoring', + region: 'us-ashburn-1', + }) + }) + + it('uses execution identity and scope instead of forged input context', async () => { + await executeOciMonitoringTool( + request({ + input: { + ...connection, + compartmentId: 'compartment', + userId: 'forged-user', + workspaceId: 'forged-workspace', + workflowId: 'forged-workflow', + resolvedCredentialId: 'forged-credential', + serviceId: 'oci_logging', + }, + }) + ) + + expect(mocks.authorize).toHaveBeenCalledWith( + expect.objectContaining({ success: true, userId: 'trusted-user' }), + { + credentialId: 'requested-credential', + workspaceId: 'trusted-workspace', + workflowId: 'trusted-workflow', + callerUserId: 'trusted-user', + } + ) + expect(mocks.execute.mock.calls[0][2]).not.toHaveProperty('workspaceId') + }) + + it.each(['userId', 'workspaceId'] as const)( + 'rejects a missing trusted %s even when supplied in input', + async (field) => { + const call = request() + call.context[field] = undefined + call.input = { ...connection, compartmentId: 'compartment', [field]: 'forged' } + + expect((await executeOciMonitoringTool(call)).status).toBe(403) + expect(mocks.authorize).not.toHaveBeenCalled() + expect(mocks.createClient).not.toHaveBeenCalled() + } + ) + + it.each([ + { ok: false }, + { ok: true, credentialType: 'oauth', resolvedCredentialId: 'wrong-kind' }, + { ok: true, credentialType: 'service_account' }, + ])('rejects unauthorized credentials before client creation: %j', async (access) => { + mocks.authorize.mockResolvedValueOnce(access) + expect((await executeOciMonitoringTool(request())).status).toBe(403) + expect(mocks.createClient).not.toHaveBeenCalled() + expect(mocks.execute).not.toHaveBeenCalled() + }) + + it('rejects invalid input before credential or provider work', async () => { + const response = await executeOciMonitoringTool(request({ input: connection })) + expect(response.status).toBe(400) + expect(mocks.authorize).not.toHaveBeenCalled() + }) + + it.each(['oci_monitoring_unknown', 'oci_monitoring_toString', 'wrong_list_metrics'])( + 'rejects an unregistered operation %s', + async (toolId) => { + expect((await executeOciMonitoringTool(request({ toolId }))).status).toBe(400) + expect(mocks.authorize).not.toHaveBeenCalled() + } + ) + + it('preserves partial ingestion details and non-retryability', async () => { + const failure = { + success: false, + retryable: false, + error: 'Rejected metric', + output: { + failedMetricsCount: 1, + failedMetrics: [{ message: 'invalid', metricData: metric }], + }, + } + mocks.execute.mockResolvedValueOnce(failure) + const response = await executeOciMonitoringTool( + request({ + toolId: 'oci_monitoring_post_metric_data', + input: { ...connection, metricData: [metric] }, + }) + ) + await expect(response.json()).resolves.toEqual(failure) + }) + + it('marks ingestion transport failures non-retryable and retains the request ID', async () => { + mocks.execute.mockRejectedValueOnce( + new OciClientError('request_failed', { status: 503, opcRequestId: 'failed-request' }) + ) + const response = await executeOciMonitoringTool( + request({ + toolId: 'oci_monitoring_post_metric_data', + input: { ...connection, metricData: [metric] }, + }) + ) + expect(response.status).toBe(503) + await expect(response.json()).resolves.toMatchObject({ + success: false, + retryable: false, + output: { opcRequestId: 'failed-request' }, + }) + }) + + it('maps foundation credential failures to authorization failures', async () => { + mocks.createClient.mockRejectedValueOnce(new OciClientError('credential_unavailable')) + expect((await executeOciMonitoringTool(request())).status).toBe(403) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + it('classifies the response byte limit as a deterministic failure with narrowing guidance', async () => { + mocks.execute.mockRejectedValueOnce(new OciClientError('response_too_large')) + const response = await executeOciMonitoringTool(request()) + expect(response.status).toBe(413) + await expect(response.json()).resolves.toMatchObject({ + retryable: false, + error: expect.stringContaining('8 MiB response budget; narrow'), + }) + }) + + it('does not expose unexpected errors or credential material', async () => { + mocks.createClient.mockRejectedValueOnce(new Error('private-key-canary')) + const response = await executeOciMonitoringTool(request()) + expect(await response.text()).not.toContain('private-key-canary') + }) + + it('preserves cancellation during authorization', async () => { + const controller = new AbortController() + mocks.authorize.mockImplementationOnce(async () => { + controller.abort() + return { ok: true, credentialType: 'service_account', resolvedCredentialId: 'credential' } + }) + await expect( + executeOciMonitoringTool( + request({ + signal: controller.signal, + }) + ) + ).rejects.toMatchObject({ name: 'AbortError' }) + expect(mocks.createClient).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/internal/oci-monitoring/execute-tool.ts b/apps/sim/lib/internal/oci-monitoring/execute-tool.ts new file mode 100644 index 00000000000..8555b3083d0 --- /dev/null +++ b/apps/sim/lib/internal/oci-monitoring/execute-tool.ts @@ -0,0 +1,124 @@ +import { authorizeCredentialUseForAuth } from '@/lib/auth/credential-access' +import { AuthType } from '@/lib/auth/hybrid' +import { createOciClient } from '@/lib/internal/oci/client.server' +import { OciClientError } from '@/lib/internal/oci/errors' +import { + type OciMonitoringOperation, + ociMonitoringInputSchemas, +} from '@/lib/internal/oci-monitoring/input' +import { + executeOciMonitoringOperation, + OciMonitoringInputError, +} from '@/lib/internal/oci-monitoring/operations' +import type { InternalToolOperationHandler } from '@/lib/internal/tool-operations/types' + +export const executeOciMonitoringTool: InternalToolOperationHandler = async (request) => { + request.signal?.throwIfAborted() + const operation = request.toolId.slice('oci_monitoring_'.length) + if ( + !request.toolId.startsWith('oci_monitoring_') || + !Object.hasOwn(ociMonitoringInputSchemas, operation) + ) { + return Response.json( + { success: false, error: 'Unsupported OCI Monitoring operation' }, + { status: 400 } + ) + } + const parsed = ociMonitoringInputSchemas[operation as OciMonitoringOperation].safeParse( + request.input + ) + if (!parsed.success) { + return Response.json( + { + success: false, + retryable: false, + error: 'Invalid OCI Monitoring input', + details: parsed.error.issues.map((issue) => ({ + path: issue.path, + message: issue.message, + })), + }, + { status: 400 } + ) + } + const { userId, workspaceId, workflowId } = request.context + if (!userId || !workspaceId) { + return Response.json( + { + success: false, + retryable: false, + error: 'OCI Monitoring requires authenticated workspace context', + }, + { status: 403 } + ) + } + try { + const access = await authorizeCredentialUseForAuth( + { success: true, userId, authType: AuthType.INTERNAL_JWT }, + { + credentialId: parsed.data.oauthCredential, + workspaceId, + workflowId: workflowId || undefined, + callerUserId: userId, + } + ) + if (!access.ok || !access.resolvedCredentialId || access.credentialType !== 'service_account') { + return Response.json( + { success: false, retryable: false, error: 'OCI credential is unavailable' }, + { status: 403 } + ) + } + request.signal?.throwIfAborted() + const client = await createOciClient({ + credentialId: access.resolvedCredentialId, + workspaceId, + serviceId: 'oci_monitoring', + region: parsed.data.region, + }) + const result = await executeOciMonitoringOperation( + client, + operation as OciMonitoringOperation, + parsed.data, + request.signal + ) + return Response.json(result) + } catch (error) { + request.signal?.throwIfAborted() + if (error instanceof OciClientError && error.code === 'response_too_large') { + return Response.json( + { + success: false, + retryable: false, + error: + 'OCI Monitoring exceeded the 8 MiB response budget; narrow the MQL, time range, filters or page size', + output: { opcRequestId: error.opcRequestId ?? null }, + }, + { status: 413 } + ) + } + const status = + error instanceof OciMonitoringInputError + ? 400 + : error instanceof OciClientError + ? error.code === 'credential_unavailable' + ? 403 + : error.code === 'invalid_request' || error.code === 'invalid_endpoint' + ? 400 + : (error.status ?? 502) + : 502 + return Response.json( + { + success: false, + retryable: operation === 'post_metric_data' ? false : status === 429 || status >= 500, + error: + error instanceof OciClientError || error instanceof OciMonitoringInputError + ? error.message + : 'OCI Monitoring request failed', + output: { + opcRequestId: error instanceof OciClientError ? (error.opcRequestId ?? null) : null, + }, + }, + { status } + ) + } +} diff --git a/apps/sim/lib/internal/oci-monitoring/input.ts b/apps/sim/lib/internal/oci-monitoring/input.ts new file mode 100644 index 00000000000..371127b4fd0 --- /dev/null +++ b/apps/sim/lib/internal/oci-monitoring/input.ts @@ -0,0 +1,296 @@ +import { z } from 'zod' + +const id = z.string().trim().min(1).max(512) +const text = z.string().min(1).max(4096) +const timestamp = z.string().datetime({ offset: true }) +const historyTimestamp = timestamp.refine( + (value) => Date.parse(value) <= Date.now(), + 'Suppression history timestamps cannot be in the future' +) +const strings = z.record(z.string(), z.string()) +const dimensions = strings.refine((value) => JSON.stringify(value).length <= 4000, { + message: 'Dimensions must not exceed 4000 serialized characters', +}) + +/** JSON fields accept both resolved workflow objects and the block's JSON editor text. */ +function json(schema: T) { + return z.preprocess((value) => { + if (typeof value !== 'string' || value.length > 1024 * 1024) return value + try { + return JSON.parse(value) + } catch { + return value + } + }, schema) +} + +const connection = { + oauthCredential: id, + region: z.string().trim().min(1).max(128).optional(), +} +const page = { + limit: z.number().int().min(1).max(1000).default(100), + page: z.string().min(1).max(4096).optional(), +} +const compartment = { + compartmentId: id, + compartmentIdInSubtree: z.boolean().optional(), +} +const sort = { sortOrder: z.enum(['ASC', 'DESC']).optional() } +const alarmId = { alarmId: id } +const ifMatch = { ifMatch: z.string().min(1).max(1024).optional() } +const retryToken = { opcRetryToken: z.string().min(1).max(64).optional() } +const severity = z.enum(['CRITICAL', 'ERROR', 'WARNING', 'INFO']) +const suppression = z.object({ + description: z.string().max(4000).optional(), + timeSuppressFrom: timestamp, + timeSuppressUntil: timestamp, +}) +const tags = { + freeformTags: json(strings).optional(), + definedTags: json(z.record(z.string(), z.record(z.string(), z.unknown()))).optional(), +} +const alarmFields = { + displayName: z.string().min(1).max(256), + compartmentId: id, + metricCompartmentId: id, + metricCompartmentIdInSubtree: z.boolean().optional(), + namespace: text, + resourceGroup: text.nullable().optional(), + query: text, + resolution: z.literal('1m').optional(), + pendingDuration: z.string().min(1).max(64).optional(), + severity, + body: z.string().max(4000).optional(), + isNotificationsPerMetricDimensionEnabled: z.boolean().optional(), + messageFormat: z.enum(['RAW', 'PRETTY_JSON', 'ONS_OPTIMIZED']).optional(), + destinations: json(z.array(id).min(1).max(2)), + repeatNotificationDuration: z.string().min(1).max(64).optional(), + suppression: json(suppression).optional(), + isEnabled: z.boolean(), + ...tags, + overrides: json( + z + .array( + z.object({ + ruleName: z.string().min(1).max(64).optional(), + query: text.optional(), + severity: severity.optional(), + pendingDuration: z.string().min(1).max(64).optional(), + body: z.string().max(4000).optional(), + }) + ) + .max(2) + ).optional(), + ruleName: z.string().min(1).max(64).optional(), + notificationVersion: z.string().min(1).max(64).optional(), + notificationTitle: z.string().max(256).optional(), + evaluationSlackDuration: z.string().min(1).max(64).optional(), + alarmSummary: z.string().max(4000).optional(), +} +const metricDimensions = z + .record( + z + .string() + .min(1) + .max(256) + .regex(/^[\x21-\x7e]+$/), + z.string().min(1).max(512) + ) + .refine((value) => Object.keys(value).length >= 1 && Object.keys(value).length <= 20, { + message: 'Each metric must have between 1 and 20 dimensions', + }) +const metric = z.object({ + compartmentId: id, + namespace: z + .string() + .regex(/^[A-Za-z][A-Za-z0-9_]*$/) + .refine( + (value) => !value.startsWith('oci_') && !value.startsWith('oracle_'), + 'Custom namespaces cannot use the oci_ or oracle_ prefixes' + ), + name: z.string().regex(/^[A-Za-z][A-Za-z0-9._$-]*$/), + resourceGroup: z + .string() + .regex(/^[A-Za-z][A-Za-z0-9._$-]*$/) + .optional(), + dimensions: metricDimensions, + metadata: z.record(z.string().min(1).max(256), z.string().max(256)).optional(), + datapoints: z + .array( + z.object({ + timestamp, + value: z.number().finite(), + count: z.number().int().min(1).optional(), + }) + ) + .min(1) + .max(100000), +}) + +export const ociMonitoringInputSchemas = { + list_metrics: z.object({ + ...connection, + ...compartment, + ...page, + ...sort, + name: text.optional(), + namespace: text.optional(), + resourceGroup: text.nullable().optional(), + dimensionFilters: json(strings).optional(), + groupBy: json( + z + .array(z.enum(['namespace', 'name', 'resourceGroup'])) + .min(1) + .max(3) + ).optional(), + sortBy: z.enum(['NAMESPACE', 'NAME', 'RESOURCEGROUP']).optional(), + }), + summarize_metrics_data: z + .object({ + ...connection, + ...compartment, + namespace: text, + query: text, + resourceGroup: text.nullable().optional(), + startTime: timestamp.optional(), + endTime: timestamp.optional(), + resolution: z + .string() + .regex(/^(?:[1-9]|[1-5][0-9]|60)m$|^(?:[1-9]|1[0-9]|2[0-4])h$|^1d$/) + .optional(), + maxStreams: z.number().int().min(1).max(2000).default(100), + maxDatapoints: z.number().int().min(1).max(100000).default(10000), + }) + .refine( + (value) => + !value.startTime || + !value.endTime || + Date.parse(value.startTime) < Date.parse(value.endTime), + { message: 'Start time must precede end time', path: ['endTime'] } + ), + post_metric_data: z.object({ + ...connection, + metricData: json(z.array(metric).min(1).max(50)), + batchAtomicity: z.enum(['ATOMIC', 'NON_ATOMIC']).optional(), + }), + list_alarms: z.object({ + ...connection, + ...compartment, + ...page, + ...sort, + displayName: text.optional(), + lifecycleState: z.enum(['ACTIVE', 'DELETING', 'DELETED']).optional(), + sortBy: z.enum(['displayName', 'severity']).optional(), + }), + list_alarms_status: z.object({ + ...connection, + ...compartment, + ...page, + ...sort, + displayName: text.optional(), + sortBy: z.enum(['displayName', 'severity']).optional(), + resourceId: id.optional(), + serviceName: text.optional(), + entityId: id.optional(), + status: z.enum(['FIRING', 'OK']).optional(), + }), + get_alarm: z.object({ ...connection, ...alarmId }), + get_alarm_history: z.object({ + ...connection, + ...alarmId, + ...page, + alarmHistorytype: z + .enum([ + 'STATE_HISTORY', + 'STATE_TRANSITION_HISTORY', + 'RULE_HISTORY', + 'RULE_TRANSITION_HISTORY', + ]) + .optional(), + timestampGreaterThanOrEqualTo: timestamp.optional(), + timestampLessThan: timestamp.optional(), + }), + retrieve_dimension_states: z.object({ + ...connection, + ...alarmId, + ...page, + dimensionFilters: json(strings).optional(), + status: z.enum(['FIRING', 'OK']).optional(), + }), + create_alarm: z.object({ ...connection, ...alarmFields, ...retryToken }), + update_alarm: z.object({ + ...connection, + ...alarmId, + ...ifMatch, + ...z.object(alarmFields).partial().shape, + }), + delete_alarm: z.object({ ...connection, ...alarmId, ...ifMatch }), + create_alarm_suppression: z + .object({ + ...connection, + ...alarmId, + ...retryToken, + ...tags, + displayName: z.string().min(1).max(256), + description: z.string().max(4000).optional(), + level: z.enum(['ALARM', 'DIMENSION']).default('ALARM'), + dimensions: json(dimensions).optional(), + timeSuppressFrom: timestamp, + timeSuppressUntil: timestamp, + }) + .superRefine((value, context) => { + if (Date.parse(value.timeSuppressFrom) >= Date.parse(value.timeSuppressUntil)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Suppression end must follow its start', + path: ['timeSuppressUntil'], + }) + } + if ( + value.level === 'DIMENSION' && + (!value.dimensions || !Object.keys(value.dimensions).length) + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Dimension suppression requires nonempty dimensions', + path: ['dimensions'], + }) + } + if (value.level === 'ALARM' && value.dimensions && Object.keys(value.dimensions).length) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Alarm-wide suppression does not take dimensions', + path: ['dimensions'], + }) + } + }), + list_alarm_suppressions: z.object({ + ...connection, + ...alarmId, + ...page, + ...sort, + displayName: text.optional(), + lifecycleState: z.enum(['ACTIVE', 'DELETED']).optional(), + level: z.enum(['ALARM', 'DIMENSION']).optional(), + isAllSuppressions: z.boolean().optional(), + sortBy: z.enum(['displayName', 'timeCreated', 'timeSuppressFrom']).optional(), + }), + get_alarm_suppression: z.object({ ...connection, alarmSuppressionId: id }), + delete_alarm_suppression: z.object({ ...connection, alarmSuppressionId: id, ...ifMatch }), + summarize_alarm_suppression_history: z.object({ + ...connection, + ...alarmId, + ...page, + dimensions: json(strings).optional(), + timeSuppressFromGreaterThanOrEqualTo: historyTimestamp.optional(), + timeSuppressFromLessThan: historyTimestamp.optional(), + }), + remove_alarm_suppression: z.object({ ...connection, ...alarmId, ...ifMatch }), +} as const + +export type OciMonitoringOperation = keyof typeof ociMonitoringInputSchemas +export type OciMonitoringInput = + z.output<(typeof ociMonitoringInputSchemas)[K]> +export type OciMonitoringParams = + z.input<(typeof ociMonitoringInputSchemas)[K]> diff --git a/apps/sim/lib/internal/oci-monitoring/operations.test.ts b/apps/sim/lib/internal/oci-monitoring/operations.test.ts new file mode 100644 index 00000000000..a44c47967ba --- /dev/null +++ b/apps/sim/lib/internal/oci-monitoring/operations.test.ts @@ -0,0 +1,708 @@ +/** + * @vitest-environment node + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { OciClient, OciRequest } from '@/lib/internal/oci/client.server' +import { OciClientError } from '@/lib/internal/oci/errors' +import { + type OciMonitoringOperation, + ociMonitoringInputSchemas, +} from '@/lib/internal/oci-monitoring/input' +import { executeOciMonitoringOperation } from '@/lib/internal/oci-monitoring/operations' + +const alarm = { + id: 'alarm/one', + displayName: 'CPU', + compartmentId: 'compartment', + metricCompartmentId: 'metrics-compartment', + namespace: 'oci_computeagent', + query: 'CpuUtilization[1m].mean() > 80', + severity: 'WARNING', + destinations: ['topic'], + isEnabled: false, + lifecycleState: 'ACTIVE', +} +const suppression = { + id: 'suppression/one', + compartmentId: 'compartment', + alarmSuppressionTarget: { targetType: 'ALARM', alarmId: alarm.id }, + level: 'ALARM', + displayName: 'Maintenance', + timeSuppressFrom: '2026-09-05T12:00:00Z', + timeSuppressUntil: '2026-09-05T13:00:00Z', + lifecycleState: 'ACTIVE', +} +const point = { timestamp: '2026-09-05T12:00:00Z', value: 0 } +const metric = { + compartmentId: 'compartment', + namespace: 'my_app', + name: 'Requests', + dimensions: { resourceId: 'resource' }, + datapoints: [point], +} +const series = { ...metric, aggregatedDatapoints: [point] } +const alarmInput = { + displayName: alarm.displayName, + compartmentId: alarm.compartmentId, + metricCompartmentId: alarm.metricCompartmentId, + namespace: alarm.namespace, + query: alarm.query, + severity: alarm.severity, + destinations: alarm.destinations, + isEnabled: false, +} +const cases: { + operation: OciMonitoringOperation + input: Record + method: string + path: string + response: unknown + output: string | null +}[] = [ + { + operation: 'list_metrics', + input: { compartmentId: 'compartment' }, + method: 'POST', + path: '/metrics/actions/listMetrics', + response: [], + output: 'metrics', + }, + { + operation: 'summarize_metrics_data', + input: { + compartmentId: 'compartment', + namespace: 'my_app', + query: 'Requests[1m].mean()', + }, + method: 'POST', + path: '/metrics/actions/summarizeMetricsData', + response: [series], + output: 'metricData', + }, + { + operation: 'post_metric_data', + input: { metricData: [metric] }, + method: 'POST', + path: '/metrics', + response: { failedMetricsCount: 0, failedMetrics: [] }, + output: 'failedMetrics', + }, + { + operation: 'list_alarms', + input: { compartmentId: 'compartment' }, + method: 'GET', + path: '/alarms', + response: [alarm], + output: 'alarms', + }, + { + operation: 'list_alarms_status', + input: { compartmentId: 'compartment' }, + method: 'GET', + path: '/alarms/status', + response: [{ id: alarm.id, displayName: 'CPU', severity: 'WARNING', status: 'OK' }], + output: 'alarmStatuses', + }, + { + operation: 'get_alarm', + input: { alarmId: alarm.id }, + method: 'GET', + path: '/alarms/alarm%2Fone', + response: alarm, + output: 'alarm', + }, + { + operation: 'get_alarm_history', + input: { alarmId: alarm.id }, + method: 'GET', + path: '/alarms/alarm%2Fone/history', + response: { alarmId: alarm.id, isEnabled: false, entries: [point] }, + output: 'history', + }, + { + operation: 'retrieve_dimension_states', + input: { alarmId: alarm.id }, + method: 'POST', + path: '/alarms/alarm%2Fone/actions/retrieveDimensionStates', + response: { + alarmId: alarm.id, + isEnabled: false, + isNotificationsPerMetricDimensionEnabled: false, + items: [{ dimensions: metric.dimensions, timestamp: point.timestamp, status: 'OK' }], + }, + output: 'dimensionStates', + }, + { + operation: 'create_alarm', + input: alarmInput, + method: 'POST', + path: '/alarms', + response: alarm, + output: 'alarm', + }, + { + operation: 'update_alarm', + input: { alarmId: alarm.id, isEnabled: false }, + method: 'PUT', + path: '/alarms/alarm%2Fone', + response: alarm, + output: 'alarm', + }, + { + operation: 'delete_alarm', + input: { alarmId: alarm.id }, + method: 'DELETE', + path: '/alarms/alarm%2Fone', + response: undefined, + output: null, + }, + { + operation: 'create_alarm_suppression', + input: { + alarmId: alarm.id, + displayName: suppression.displayName, + timeSuppressFrom: suppression.timeSuppressFrom, + timeSuppressUntil: suppression.timeSuppressUntil, + }, + method: 'POST', + path: '/alarmSuppressions', + response: suppression, + output: 'alarmSuppression', + }, + { + operation: 'list_alarm_suppressions', + input: { alarmId: alarm.id }, + method: 'GET', + path: '/alarmSuppressions', + response: { items: [suppression] }, + output: 'alarmSuppressions', + }, + { + operation: 'get_alarm_suppression', + input: { alarmSuppressionId: suppression.id }, + method: 'GET', + path: '/alarmSuppressions/suppression%2Fone', + response: suppression, + output: 'alarmSuppression', + }, + { + operation: 'delete_alarm_suppression', + input: { alarmSuppressionId: suppression.id }, + method: 'DELETE', + path: '/alarmSuppressions/suppression%2Fone', + response: undefined, + output: null, + }, + { + operation: 'summarize_alarm_suppression_history', + input: { alarmId: alarm.id }, + method: 'POST', + path: '/alarms/alarm%2Fone/actions/summarizeAlarmSuppressionHistory', + response: { + items: [ + { + suppressionId: suppression.id, + alarmSuppressionTarget: suppression.alarmSuppressionTarget, + level: 'ALARM', + displayName: 'Maintenance', + timeEffectiveFrom: point.timestamp, + }, + ], + }, + output: 'suppressionHistory', + }, + { + operation: 'remove_alarm_suppression', + input: { alarmId: alarm.id }, + method: 'POST', + path: '/alarms/alarm%2Fone/actions/removeSuppression', + response: undefined, + output: null, + }, +] + +const request = vi.fn() +const prepareStaticEndpoint = vi.fn() +const client: OciClient = { + request, + prepareStaticEndpoint, + prepareDiscoveredEndpoint: vi.fn(), +} + +function reply(data: unknown, headers: Record = {}) { + request.mockResolvedValue({ + status: data === undefined ? 204 : 200, + headers, + opcRequestId: 'oracle-request', + body: new TextEncoder().encode(data === undefined ? '' : JSON.stringify(data)), + }) +} + +function execute( + operation: OciMonitoringOperation, + input: Record, + signal?: AbortSignal +) { + return executeOciMonitoringOperation( + client, + operation, + ociMonitoringInputSchemas[operation].parse({ oauthCredential: 'credential', ...input }), + signal + ) +} + +function sent(): OciRequest { + return request.mock.calls[0][0] +} + +function body() { + return JSON.parse(new TextDecoder().decode(sent().body)) +} + +describe('OCI Monitoring operations', () => { + afterEach(() => vi.restoreAllMocks()) + + beforeEach(() => { + vi.clearAllMocks() + vi.spyOn(Date, 'now').mockReturnValue(Date.parse(point.timestamp)) + prepareStaticEndpoint.mockResolvedValue({ hostname: 'prepared-by-foundation' }) + }) + + it.each(cases)('uses the documented contract for $operation', async (testCase) => { + reply(testCase.response, { etag: 'version' }) + const controller = new AbortController() + + const result = await execute(testCase.operation, testCase.input, controller.signal) + + expect(result.success).toBe(true) + expect(result.output.opcRequestId).toBe('oracle-request') + if (testCase.output) expect(result.output).toHaveProperty(testCase.output) + else expect(result.output).toEqual({ opcRequestId: 'oracle-request' }) + expect(request).toHaveBeenCalledTimes(1) + expect(sent()).toMatchObject({ + method: testCase.method, + encodedPath: `/20180401${testCase.path}`, + maxResponseBytes: 8 * 1024 * 1024, + timeoutMs: 60_000, + signal: controller.signal, + responseHeaders: ['opc-next-page', 'etag'], + }) + expect(prepareStaticEndpoint).toHaveBeenCalledWith( + expect.objectContaining({ + serviceId: 'oci_monitoring', + serviceName: + testCase.operation === 'post_metric_data' ? 'telemetry-ingestion' : 'telemetry', + }) + ) + expect(sent().retry).toEqual( + testCase.method === 'GET' ? { kind: 'safe', maxAttempts: 3 } : undefined + ) + }) + + it.each([ + ['list_metrics', [], { compartmentId: 'compartment' }], + ['list_alarms', [], { compartmentId: 'compartment' }], + [ + 'get_alarm_history', + { alarmId: alarm.id, isEnabled: false, entries: [] }, + { alarmId: alarm.id }, + ], + [ + 'retrieve_dimension_states', + { + alarmId: alarm.id, + isEnabled: false, + isNotificationsPerMetricDimensionEnabled: false, + items: [], + }, + { alarmId: alarm.id }, + ], + ['list_alarm_suppressions', { items: [] }, { alarmId: alarm.id }], + ['summarize_alarm_suppression_history', { items: [] }, { alarmId: alarm.id }], + ] as const)('preserves an empty page cursor for %s', async (operation, response, input) => { + reply(response, { 'opc-next-page': 'next::+=/' }) + + const result = await execute(operation, { ...input, page: 'previous::+=/' }) + + expect(result.output.nextPage).toBe('next::+=/') + expect(sent().queryPairs).toEqual( + expect.arrayContaining([ + ['page', 'previous::+=/'], + ['limit', '100'], + ]) + ) + expect(request).toHaveBeenCalledTimes(1) + }) + + it('keeps MQL exact and omits optional query timestamps to preserve Oracle defaults', async () => { + reply([]) + const query = 'Requests[5m]{resourceId = "a"}.groupBy(resourceId).percentile(0.9)' + await execute('summarize_metrics_data', { + compartmentId: 'compartment', + compartmentIdInSubtree: false, + namespace: 'my_app', + query, + resolution: '1m', + }) + + expect(body()).toEqual({ namespace: 'my_app', query, resolution: '1m' }) + expect(sent().queryPairs).toContainEqual(['compartmentIdInSubtree', 'false']) + }) + + it('puts metric discovery filters in the body and preserves a null resource group', async () => { + reply([]) + await execute('list_metrics', { + compartmentId: 'compartment', + compartmentIdInSubtree: false, + namespace: 'my_app', + resourceGroup: null, + dimensionFilters: { resourceId: 'resource' }, + groupBy: ['namespace'], + sortBy: 'NAMESPACE', + sortOrder: 'ASC', + limit: 20, + }) + expect(body()).toEqual({ + namespace: 'my_app', + resourceGroup: null, + dimensionFilters: { resourceId: 'resource' }, + groupBy: ['namespace'], + sortBy: 'NAMESPACE', + sortOrder: 'ASC', + }) + expect(sent().queryPairs).toEqual([ + ['compartmentId', 'compartment'], + ['compartmentIdInSubtree', 'false'], + ['limit', '20'], + ]) + }) + + it('uses the exact alarm-history wire key and leaves timestamp boundaries intact', async () => { + reply({ alarmId: alarm.id, isEnabled: false, entries: [] }) + await execute('get_alarm_history', { + alarmId: alarm.id, + alarmHistorytype: 'STATE_TRANSITION_HISTORY', + timestampGreaterThanOrEqualTo: '2026-09-05T01:00:00-07:00', + timestampLessThan: '2026-09-05T02:00:00-07:00', + }) + expect(sent().queryPairs).toEqual( + expect.arrayContaining([ + ['alarmHistorytype', 'STATE_TRANSITION_HISTORY'], + ['timestampGreaterThanOrEqualTo', '2026-09-05T01:00:00-07:00'], + ['timestampLessThan', '2026-09-05T02:00:00-07:00'], + ]) + ) + }) + + it.each(['delete_alarm', 'delete_alarm_suppression', 'remove_alarm_suppression'] as const)( + 'forwards optimistic concurrency without retrying %s', + async (operation) => { + reply(undefined) + await execute(operation, { + alarmId: alarm.id, + alarmSuppressionId: suppression.id, + ifMatch: 'version', + }) + expect(sent().headers).toEqual({ 'if-match': 'version' }) + expect(sent().retry).toBeUndefined() + } + ) + + it.each([ + { maxStreams: 1, maxDatapoints: 10 }, + { maxStreams: 10, maxDatapoints: 1 }, + ])('fails explicitly when a query exceeds its selected output budget', async (budget) => { + reply([series, series]) + await expect( + execute('summarize_metrics_data', { + compartmentId: 'compartment', + namespace: 'my_app', + query: 'Requests[1m].mean()', + ...budget, + }) + ).rejects.toThrow('narrow the MQL/time range') + }) + + it('preserves explicit false and empty update collections without sending omitted fields', async () => { + reply(alarm, { etag: 'new-version' }) + const result = await execute('update_alarm', { + alarmId: alarm.id, + isEnabled: false, + freeformTags: {}, + overrides: [], + ifMatch: 'old-version', + }) + + expect(body()).toEqual({ isEnabled: false, freeformTags: {}, overrides: [] }) + expect(sent().headers).toEqual({ 'if-match': 'old-version' }) + expect(result.output.etag).toBe('new-version') + }) + + it('rejects an empty update before contacting Oracle', async () => { + await expect(execute('update_alarm', { alarmId: alarm.id })).rejects.toThrow('at least one') + expect(prepareStaticEndpoint).not.toHaveBeenCalled() + }) + + it('accepts Oracle’s documented two-override alarm configuration', async () => { + const overrides = [ + { query: 'CpuUtilization[1m].mean() > 95', ruleName: '95', severity: 'CRITICAL' }, + { query: 'CpuUtilization[1m].mean() > 90', ruleName: '90', severity: 'WARNING' }, + ] + reply({ ...alarm, overrides }) + await execute('update_alarm', { alarmId: alarm.id, overrides }) + expect(body()).toEqual({ overrides }) + }) + + it.each(['create_alarm', 'create_alarm_suppression'] as const)( + 'enables tokenized retries only with a stable token for %s', + async (operation) => { + const testCase = cases.find((entry) => entry.operation === operation)! + reply(testCase.response) + await execute(operation, { ...testCase.input, opcRetryToken: 'stable-token' }) + + expect(sent().retry).toEqual({ + kind: 'tokenized', + maxAttempts: 3, + retryToken: 'stable-token', + }) + } + ) + + it('builds the suppression target and preserves dimension values', async () => { + reply(suppression) + await execute('create_alarm_suppression', { + alarmId: alarm.id, + displayName: 'Maintenance', + level: 'DIMENSION', + dimensions: { resourceId: 'instance' }, + timeSuppressFrom: suppression.timeSuppressFrom, + timeSuppressUntil: suppression.timeSuppressUntil, + }) + expect(body()).toMatchObject({ + alarmSuppressionTarget: { targetType: 'ALARM', alarmId: alarm.id }, + dimensions: { resourceId: 'instance' }, + level: 'DIMENSION', + }) + expect(body()).not.toHaveProperty('alarmId') + }) + + it('preserves failed records and zero datapoints without inventing accepted counts or resending', async () => { + const failed = { message: 'invalid metric', metricData: metric } + reply({ failedMetricsCount: 1, failedMetrics: [failed] }) + const result = await execute('post_metric_data', { metricData: [metric] }) + + expect(result).toMatchObject({ + success: false, + retryable: false, + output: { failedMetricsCount: 1, failedMetrics: [failed] }, + }) + expect(result.output).not.toHaveProperty('acceptedDatapoints') + expect(body().metricData[0].datapoints[0].value).toBe(0) + expect(request).toHaveBeenCalledTimes(1) + expect(sent().retry).toBeUndefined() + }) + + it.each([-2 * 60 * 60 * 1000, 10 * 60 * 1000])( + 'rejects ingestion timestamps on the excluded boundary %i', + async (offset) => { + await expect( + execute('post_metric_data', { + metricData: [ + { + ...metric, + datapoints: [ + { + timestamp: new Date(Date.now() + offset).toISOString(), + value: 0, + }, + ], + }, + ], + }) + ).rejects.toThrow('less than two hours') + expect(request).not.toHaveBeenCalled() + } + ) + + it('enforces serialized request bytes before endpoint preparation', async () => { + await expect( + execute('post_metric_data', { + metricData: [{ ...metric, datapoints: Array.from({ length: 25000 }, () => point) }], + }) + ).rejects.toThrow('1 MiB') + expect(prepareStaticEndpoint).not.toHaveBeenCalled() + }) + + it('does not catch and resend an ingestion transport failure', async () => { + const error = new OciClientError('request_failed', { status: 503 }) + request.mockRejectedValueOnce(error) + await expect(execute('post_metric_data', { metricData: [metric] })).rejects.toBe(error) + expect(request).toHaveBeenCalledTimes(1) + }) + + it('does not replace the foundation response byte cap with truncation', async () => { + const error = new OciClientError('response_too_large') + request.mockRejectedValueOnce(error) + await expect(execute('list_metrics', { compartmentId: 'compartment' })).rejects.toBe(error) + expect(sent().maxResponseBytes).toBe(8 * 1024 * 1024) + }) + + it('rejects undocumented response envelopes', async () => { + reply({ alarms: [alarm] }) + await expect( + execute('list_alarms', { + compartmentId: 'compartment', + }) + ).rejects.toThrow('unexpected') + }) + + it('does not advertise full alarm fields in a list response', async () => { + reply([{ ...alarm, body: 'detail-only', timeCreated: point.timestamp }]) + const result = await execute('list_alarms', { compartmentId: 'compartment' }) + expect(result.output.alarms).toEqual([alarm]) + }) + + it('preserves cancellation before any provider work', async () => { + const controller = new AbortController() + controller.abort() + await expect( + execute( + 'get_alarm', + { + alarmId: alarm.id, + }, + controller.signal + ) + ).rejects.toMatchObject({ name: 'AbortError' }) + expect(prepareStaticEndpoint).not.toHaveBeenCalled() + }) +}) + +describe('OCI Monitoring input policy', () => { + it('does not apply the creation-only dimension character cap to state or history queries', () => { + const dimensions = { resourceId: 'x'.repeat(4100) } + expect( + ociMonitoringInputSchemas.retrieve_dimension_states.parse({ + oauthCredential: 'credential', + alarmId: alarm.id, + dimensionFilters: dimensions, + }).dimensionFilters + ).toEqual(dimensions) + expect( + ociMonitoringInputSchemas.summarize_alarm_suppression_history.parse({ + oauthCredential: 'credential', + alarmId: alarm.id, + dimensions, + }).dimensions + ).toEqual(dimensions) + }) + + it('rejects future suppression history filters', () => { + expect( + ociMonitoringInputSchemas.summarize_alarm_suppression_history.safeParse({ + oauthCredential: 'credential', + alarmId: alarm.id, + timeSuppressFromLessThan: new Date(Date.now() + 60_000).toISOString(), + }).success + ).toBe(false) + }) + + it('uses the documented removal operation instead of accepting a null suppression update', () => { + expect( + ociMonitoringInputSchemas.update_alarm.safeParse({ + oauthCredential: 'credential', + alarmId: alarm.id, + suppression: null, + }).success + ).toBe(false) + }) + + it.each([ + { namespace: 'oci_custom' }, + { namespace: 'oracle_custom' }, + { dimensions: {} }, + { dimensions: { 'invalid key': 'value' } }, + { dimensions: { key: '' } }, + { dimensions: Object.fromEntries(Array.from({ length: 21 }, (_, i) => [`key${i}`, 'v'])) }, + ])('rejects invalid custom metric identity %j', (patch) => { + expect( + ociMonitoringInputSchemas.post_metric_data.safeParse({ + oauthCredential: 'credential', + metricData: [{ ...metric, ...patch }], + }).success + ).toBe(false) + }) + + it('keeps discovery dimensions separate from ingestion policy and accepts resolved JSON', () => { + expect( + ociMonitoringInputSchemas.list_metrics.parse({ + oauthCredential: 'credential', + compartmentId: 'compartment', + dimensionFilters: '{"service label":"値"}', + groupBy: '["namespace"]', + }) + ).toMatchObject({ dimensionFilters: { 'service label': '値' }, groupBy: ['namespace'] }) + }) + + it('bounds ingestion records and query budgets', () => { + expect( + ociMonitoringInputSchemas.post_metric_data.safeParse({ + oauthCredential: 'credential', + metricData: Array.from({ length: 51 }, () => metric), + }).success + ).toBe(false) + expect( + ociMonitoringInputSchemas.summarize_metrics_data.safeParse({ + oauthCredential: 'credential', + compartmentId: 'compartment', + namespace: 'my_app', + query: 'Requests[1m].mean()', + maxStreams: 2001, + }).success + ).toBe(false) + }) + + it('validates RFC3339 query order without parsing or rewriting MQL', () => { + const input = { + oauthCredential: 'credential', + compartmentId: 'compartment', + namespace: 'my_app', + query: 'Requests[1m].mean()', + } + expect( + ociMonitoringInputSchemas.summarize_metrics_data.safeParse({ + ...input, + startTime: point.timestamp, + endTime: point.timestamp, + }).success + ).toBe(false) + expect( + ociMonitoringInputSchemas.summarize_metrics_data.safeParse({ + ...input, + startTime: '1757073600', + }).success + ).toBe(false) + }) + + it('requires dimension suppression targets and an increasing suppression window', () => { + const input = { + oauthCredential: 'credential', + alarmId: alarm.id, + displayName: 'Maintenance', + timeSuppressFrom: suppression.timeSuppressFrom, + timeSuppressUntil: suppression.timeSuppressUntil, + } + expect( + ociMonitoringInputSchemas.create_alarm_suppression.safeParse({ + ...input, + level: 'DIMENSION', + }).success + ).toBe(false) + expect( + ociMonitoringInputSchemas.create_alarm_suppression.safeParse({ + ...input, + timeSuppressUntil: input.timeSuppressFrom, + }).success + ).toBe(false) + }) +}) diff --git a/apps/sim/lib/internal/oci-monitoring/operations.ts b/apps/sim/lib/internal/oci-monitoring/operations.ts new file mode 100644 index 00000000000..efe62d08cbd --- /dev/null +++ b/apps/sim/lib/internal/oci-monitoring/operations.ts @@ -0,0 +1,548 @@ +import { z } from 'zod' +import type { OciClient, OciRequest } from '@/lib/internal/oci/client.server' +import { createOciStaticEndpointPolicy } from '@/lib/internal/oci/endpoints' +import type { + OciMonitoringInput, + OciMonitoringOperation, +} from '@/lib/internal/oci-monitoring/input' +import type { ToolResponse } from '@/tools/types' + +export const OCI_MONITORING_ENDPOINT = createOciStaticEndpointPolicy({ + serviceId: 'oci_monitoring', + serviceName: 'telemetry', + hostnameTemplate: 'regional', +}) +const INGESTION_ENDPOINT = createOciStaticEndpointPolicy({ + serviceId: 'oci_monitoring', + serviceName: 'telemetry-ingestion', + hostnameTemplate: 'regional', +}) +const MAX_RESPONSE_BYTES = 8 * 1024 * 1024 +const MAX_REQUEST_BYTES = 1024 * 1024 +const TIMEOUT_MS = 60_000 +const optionalString = z.string().nullable().optional() +const optionalBoolean = z.boolean().nullable().optional() +const stringMap = z.record(z.string(), z.string()) +const tags = { + freeformTags: stringMap.nullable().optional(), + definedTags: z.record(z.string(), z.record(z.string(), z.unknown())).nullable().optional(), +} +const suppressionSchema = z.object({ + description: optionalString, + timeSuppressFrom: z.string(), + timeSuppressUntil: z.string(), +}) +const overrideSchema = z.object({ + ruleName: optionalString, + query: optionalString, + severity: optionalString, + pendingDuration: optionalString, + body: optionalString, +}) +const metricSchema = z.object({ + name: optionalString, + namespace: optionalString, + resourceGroup: optionalString, + compartmentId: optionalString, + dimensions: stringMap.nullable().optional(), +}) +const datapointSchema = z.object({ timestamp: z.string(), value: z.number() }) +const metricDataSchema = z.object({ + namespace: z.string(), + name: z.string(), + compartmentId: z.string(), + dimensions: stringMap, + resourceGroup: optionalString, + metadata: stringMap.nullable().optional(), + resolution: optionalString, + aggregatedDatapoints: z.array(datapointSchema), +}) +const alarmSchema = z.object({ + id: z.string(), + displayName: z.string(), + compartmentId: z.string(), + metricCompartmentId: z.string(), + metricCompartmentIdInSubtree: optionalBoolean, + namespace: z.string(), + resourceGroup: optionalString, + query: z.string(), + resolution: optionalString, + pendingDuration: optionalString, + severity: z.string(), + body: optionalString, + isNotificationsPerMetricDimensionEnabled: optionalBoolean, + messageFormat: optionalString, + destinations: z.array(z.string()), + repeatNotificationDuration: optionalString, + suppression: suppressionSchema.nullable().optional(), + isEnabled: z.boolean(), + ...tags, + overrides: z.array(overrideSchema).nullable().optional(), + ruleName: optionalString, + notificationVersion: optionalString, + notificationTitle: optionalString, + evaluationSlackDuration: optionalString, + alarmSummary: optionalString, + lifecycleState: z.string(), + timeCreated: optionalString, + timeUpdated: optionalString, +}) +const alarmStatusSchema = z.object({ + id: z.string(), + displayName: z.string(), + severity: z.string(), + ruleName: optionalString, + timestampTriggered: optionalString, + alarmSummary: optionalString, + status: z.string(), + suppression: suppressionSchema.nullable().optional(), +}) +const alarmSummarySchema = alarmSchema.omit({ + metricCompartmentIdInSubtree: true, + resolution: true, + pendingDuration: true, + body: true, + messageFormat: true, + repeatNotificationDuration: true, + timeCreated: true, + timeUpdated: true, +}) +const historyEntrySchema = z.object({ + alarmSummary: optionalString, + summary: optionalString, + timestamp: z.string(), + timestampTriggered: optionalString, +}) +const dimensionStateSchema = z.object({ + alarmSummary: optionalString, + dimensions: stringMap, + status: z.string(), + ruleName: optionalString, + timestamp: z.string(), +}) +const targetSchema = z.object({ + targetType: z.string(), + alarmId: optionalString, + compartmentId: optionalString, + compartmentIdInSubtree: optionalBoolean, +}) +const conditionSchema = z.object({ + conditionType: z.string(), + suppressionRecurrence: optionalString, + suppressionDuration: optionalString, +}) +const alarmSuppressionSchema = z.object({ + id: z.string(), + compartmentId: z.string(), + alarmSuppressionTarget: targetSchema, + level: z.string(), + displayName: z.string(), + description: optionalString, + dimensions: stringMap.nullable().optional(), + timeSuppressFrom: z.string(), + timeSuppressUntil: z.string(), + lifecycleState: z.string(), + timeCreated: optionalString, + timeUpdated: optionalString, + suppressionConditions: z.array(conditionSchema).nullable().optional(), + ...tags, +}) +const suppressionHistorySchema = z.object({ + suppressionId: z.string(), + alarmSuppressionTarget: targetSchema, + level: z.string(), + displayName: z.string(), + description: optionalString, + dimensions: stringMap.nullable().optional(), + timeEffectiveFrom: z.string(), + timeEffectiveUntil: optionalString, + suppressionConditions: z.array(conditionSchema).nullable().optional(), +}) +const postedMetricSchema = z.object({ + compartmentId: z.string(), + namespace: z.string(), + name: z.string(), + resourceGroup: optionalString, + dimensions: stringMap, + metadata: stringMap.nullable().optional(), + datapoints: z.array(datapointSchema.extend({ count: z.number().optional() })), +}) +const failedMetricSchema = z.object({ + message: z.string(), + metricData: postedMetricSchema, +}) + +export type OciMetric = z.output +export type OciMetricData = z.output +export type OciAlarm = z.output +export type OciAlarmSummary = z.output +export type OciAlarmStatus = z.output +export type OciAlarmHistoryEntry = z.output +export type OciDimensionState = z.output +export type OciAlarmSuppression = z.output +export type OciSuppressionHistory = z.output +export type OciFailedMetric = z.output + +export class OciMonitoringInputError extends Error {} + +const ALARM_FIELDS = [ + 'displayName', + 'compartmentId', + 'metricCompartmentId', + 'metricCompartmentIdInSubtree', + 'namespace', + 'resourceGroup', + 'query', + 'resolution', + 'pendingDuration', + 'severity', + 'body', + 'isNotificationsPerMetricDimensionEnabled', + 'messageFormat', + 'destinations', + 'repeatNotificationDuration', + 'suppression', + 'isEnabled', + 'freeformTags', + 'definedTags', + 'overrides', + 'ruleName', + 'notificationVersion', + 'notificationTitle', + 'evaluationSlackDuration', + 'alarmSummary', +] as const +const PAGE_FIELDS = ['page', 'limit'] as const +const COMPARTMENT_FIELDS = ['compartmentId', 'compartmentIdInSubtree'] as const +const ALARM_LIST_FIELDS = [ + ...COMPARTMENT_FIELDS, + ...PAGE_FIELDS, + 'displayName', + 'sortBy', + 'sortOrder', +] as const + +interface OperationRequest { + method: 'GET' | 'POST' | 'PUT' | 'DELETE' + path: string + query?: readonly string[] + body?: readonly string[] + tokenized?: boolean +} + +/** Monitoring owns its wire contracts; the shared OCI client owns transport and signing. */ +function operationRequest( + operation: OciMonitoringOperation, + input: Record +): OperationRequest { + const alarmPath = `/alarms/${encodeURIComponent(String(input.alarmId))}` + const suppressionPath = `/alarmSuppressions/${encodeURIComponent(String(input.alarmSuppressionId))}` + switch (operation) { + case 'list_metrics': + return { + method: 'POST', + path: '/metrics/actions/listMetrics', + query: [...COMPARTMENT_FIELDS, ...PAGE_FIELDS], + body: [ + 'name', + 'namespace', + 'resourceGroup', + 'dimensionFilters', + 'groupBy', + 'sortBy', + 'sortOrder', + ], + } + case 'summarize_metrics_data': + return { + method: 'POST', + path: '/metrics/actions/summarizeMetricsData', + query: COMPARTMENT_FIELDS, + body: ['namespace', 'query', 'resourceGroup', 'startTime', 'endTime', 'resolution'], + } + case 'post_metric_data': + return { method: 'POST', path: '/metrics', body: ['metricData', 'batchAtomicity'] } + case 'list_alarms': + return { method: 'GET', path: '/alarms', query: [...ALARM_LIST_FIELDS, 'lifecycleState'] } + case 'list_alarms_status': + return { + method: 'GET', + path: '/alarms/status', + query: [...ALARM_LIST_FIELDS, 'resourceId', 'serviceName', 'entityId', 'status'], + } + case 'get_alarm': + return { method: 'GET', path: alarmPath } + case 'get_alarm_history': + return { + method: 'GET', + path: `${alarmPath}/history`, + query: [ + ...PAGE_FIELDS, + 'alarmHistorytype', + 'timestampGreaterThanOrEqualTo', + 'timestampLessThan', + ], + } + case 'retrieve_dimension_states': + return { + method: 'POST', + path: `${alarmPath}/actions/retrieveDimensionStates`, + query: PAGE_FIELDS, + body: ['dimensionFilters', 'status'], + } + case 'create_alarm': + return { method: 'POST', path: '/alarms', body: ALARM_FIELDS, tokenized: true } + case 'update_alarm': + return { method: 'PUT', path: alarmPath, body: ALARM_FIELDS } + case 'delete_alarm': + return { method: 'DELETE', path: alarmPath } + case 'create_alarm_suppression': + return { + method: 'POST', + path: '/alarmSuppressions', + body: [ + 'displayName', + 'description', + 'level', + 'dimensions', + 'timeSuppressFrom', + 'timeSuppressUntil', + 'freeformTags', + 'definedTags', + ], + tokenized: true, + } + case 'list_alarm_suppressions': + return { + method: 'GET', + path: '/alarmSuppressions', + query: [ + ...PAGE_FIELDS, + 'alarmId', + 'displayName', + 'lifecycleState', + 'level', + 'isAllSuppressions', + 'sortBy', + 'sortOrder', + ], + } + case 'get_alarm_suppression': + return { method: 'GET', path: suppressionPath } + case 'delete_alarm_suppression': + return { method: 'DELETE', path: suppressionPath } + case 'summarize_alarm_suppression_history': + return { + method: 'POST', + path: `${alarmPath}/actions/summarizeAlarmSuppressionHistory`, + query: PAGE_FIELDS, + body: ['dimensions', 'timeSuppressFromGreaterThanOrEqualTo', 'timeSuppressFromLessThan'], + } + case 'remove_alarm_suppression': + return { method: 'POST', path: `${alarmPath}/actions/removeSuppression` } + } +} + +function selectFields( + input: Record, + keys: readonly string[] +): Record { + const result: Record = {} + for (const key of keys) { + if (input[key] !== undefined) result[key] = input[key] + } + return result +} + +function projectResponse( + operation: OciMonitoringOperation, + data: unknown, + limit: number +): Record { + switch (operation) { + case 'list_metrics': + return { metrics: z.array(metricSchema).max(limit).parse(data) } + case 'summarize_metrics_data': + return { metricData: z.array(metricDataSchema).max(2000).parse(data) } + case 'post_metric_data': + return z + .object({ + failedMetricsCount: z.number().int().nonnegative(), + failedMetrics: z + .array(failedMetricSchema) + .max(50) + .nullish() + .transform((value) => value ?? []), + }) + .parse(data) + case 'list_alarms': + return { alarms: z.array(alarmSummarySchema).max(limit).parse(data) } + case 'list_alarms_status': + return { alarmStatuses: z.array(alarmStatusSchema).max(limit).parse(data) } + case 'get_alarm': + case 'create_alarm': + case 'update_alarm': + return { alarm: alarmSchema.parse(data) } + case 'get_alarm_history': { + const history = z + .object({ + alarmId: z.string(), + isEnabled: z.boolean(), + entries: z.array(historyEntrySchema).max(limit), + }) + .parse(data) + return { alarmId: history.alarmId, isEnabled: history.isEnabled, history: history.entries } + } + case 'retrieve_dimension_states': { + const states = z + .object({ + alarmId: z.string(), + isEnabled: z.boolean(), + isNotificationsPerMetricDimensionEnabled: z.boolean(), + items: z.array(dimensionStateSchema).max(limit), + }) + .parse(data) + return { + alarmId: states.alarmId, + isEnabled: states.isEnabled, + isNotificationsPerMetricDimensionEnabled: states.isNotificationsPerMetricDimensionEnabled, + dimensionStates: states.items, + } + } + case 'create_alarm_suppression': + case 'get_alarm_suppression': + return { alarmSuppression: alarmSuppressionSchema.parse(data) } + case 'list_alarm_suppressions': + return { + alarmSuppressions: z + .object({ items: z.array(alarmSuppressionSchema).max(limit) }) + .parse(data).items, + } + case 'summarize_alarm_suppression_history': + return { + suppressionHistory: z + .object({ items: z.array(suppressionHistorySchema).max(limit) }) + .parse(data).items, + } + default: + return {} + } +} + +export async function executeOciMonitoringOperation( + client: OciClient, + operation: OciMonitoringOperation, + input: OciMonitoringInput, + signal?: AbortSignal +): Promise { + signal?.throwIfAborted() + const spec = operationRequest(operation, input) + const queryPairs: [string, string][] = [] + for (const [key, value] of Object.entries(selectFields(input, spec.query ?? []))) { + if (value !== null) queryPairs.push([key, String(value)]) + } + const body = selectFields(input, spec.body ?? []) + if (operation === 'create_alarm_suppression' && 'alarmId' in input) { + body.alarmSuppressionTarget = { targetType: 'ALARM', alarmId: input.alarmId } + } + if (operation === 'update_alarm' && !Object.keys(body).length) { + throw new OciMonitoringInputError('Provide at least one alarm field to update') + } + const bytes = new TextEncoder().encode(JSON.stringify(body)) + if (bytes.byteLength > MAX_REQUEST_BYTES) { + throw new OciMonitoringInputError('Monitoring request exceeds the 1 MiB Sim request limit') + } + if (operation === 'post_metric_data' && 'metricData' in input) { + const now = Date.now() + for (const metric of input.metricData) { + for (const point of metric.datapoints) { + const time = Date.parse(point.timestamp) + if (time <= now - 2 * 60 * 60 * 1000 || time >= now + 10 * 60 * 1000) { + throw new OciMonitoringInputError( + 'Metric timestamps must be less than two hours old and less than ten minutes in the future' + ) + } + } + } + } + const endpoint = await client.prepareStaticEndpoint( + operation === 'post_metric_data' ? INGESTION_ENDPOINT : OCI_MONITORING_ENDPOINT + ) + const headers: Record = {} + if ('ifMatch' in input && input.ifMatch) headers['if-match'] = input.ifMatch + const base = { + endpoint, + encodedPath: `/20180401${spec.path}`, + queryPairs, + headers, + responseHeaders: ['opc-next-page', 'etag'], + timeoutMs: TIMEOUT_MS, + maxResponseBytes: MAX_RESPONSE_BYTES, + signal, + } + let request: OciRequest + if (spec.method === 'GET') { + request = { ...base, method: 'GET', retry: { kind: 'safe', maxAttempts: 3 } } + } else if (spec.method === 'DELETE') { + request = { ...base, method: 'DELETE' } + } else { + request = { + ...base, + method: spec.method, + body: bytes, + contentType: 'application/json', + ...(spec.tokenized && 'opcRetryToken' in input && input.opcRetryToken + ? { retry: { kind: 'tokenized' as const, retryToken: input.opcRetryToken, maxAttempts: 3 } } + : {}), + } + } + const response = await client.request(request) + const emptyResponse = [ + 'delete_alarm', + 'delete_alarm_suppression', + 'remove_alarm_suppression', + ].includes(operation) + let output: Record + try { + output = emptyResponse + ? {} + : projectResponse( + operation, + JSON.parse(new TextDecoder().decode(response.body)), + 'limit' in input ? input.limit : 100 + ) + } catch { + throw new Error('OCI Monitoring returned an unexpected or oversized response') + } + if (operation === 'summarize_metrics_data' && 'maxStreams' in input) { + const series = output.metricData as OciMetricData[] + const points = series.reduce((count, metric) => count + metric.aggregatedDatapoints.length, 0) + if (series.length > input.maxStreams || points > input.maxDatapoints) { + throw new OciMonitoringInputError( + 'Metric results exceed the selected stream or datapoint budget; narrow the MQL/time range or increase the output budget' + ) + } + } + output.opcRequestId = response.opcRequestId ?? null + if (spec.query?.includes('page')) output.nextPage = response.headers['opc-next-page'] ?? null + if ( + [ + 'get_alarm', + 'create_alarm', + 'update_alarm', + 'get_alarm_suppression', + 'create_alarm_suppression', + ].includes(operation) + ) { + output.etag = response.headers.etag ?? null + } + if (operation === 'post_metric_data' && Number(output.failedMetricsCount) > 0) { + return { + success: false, + retryable: false, + error: 'OCI rejected one or more metric records; inspect failedMetrics before resubmitting', + output, + } + } + return { success: true, output } +} diff --git a/apps/sim/lib/internal/tool-operations/registry.server.ts b/apps/sim/lib/internal/tool-operations/registry.server.ts index b683b447938..db5236b86fc 100644 --- a/apps/sim/lib/internal/tool-operations/registry.server.ts +++ b/apps/sim/lib/internal/tool-operations/registry.server.ts @@ -213,6 +213,26 @@ const CLOUDTRAIL_TOOL_IDS = [ 'cloudtrail_start_query', ] as const +const OCI_MONITORING_TOOL_IDS = [ + 'oci_monitoring_create_alarm', + 'oci_monitoring_create_alarm_suppression', + 'oci_monitoring_delete_alarm', + 'oci_monitoring_delete_alarm_suppression', + 'oci_monitoring_get_alarm', + 'oci_monitoring_get_alarm_history', + 'oci_monitoring_get_alarm_suppression', + 'oci_monitoring_list_alarm_suppressions', + 'oci_monitoring_list_alarms', + 'oci_monitoring_list_alarms_status', + 'oci_monitoring_list_metrics', + 'oci_monitoring_post_metric_data', + 'oci_monitoring_remove_alarm_suppression', + 'oci_monitoring_retrieve_dimension_states', + 'oci_monitoring_summarize_alarm_suppression_history', + 'oci_monitoring_summarize_metrics_data', + 'oci_monitoring_update_alarm', +] as const + const CLOUDWATCH_TOOL_IDS = [ 'cloudwatch_describe_alarm_history', 'cloudwatch_describe_alarms', @@ -1413,6 +1433,9 @@ registerFamily(handlerLoaders, TEXTRACT_TOOL_IDS, async () => { registerFamily(handlerLoaders, CLOUDWATCH_TOOL_IDS, async () => { return (await import('@/lib/internal/cloudwatch/execute-tool')).executeCloudwatchTool }) +registerFamily(handlerLoaders, OCI_MONITORING_TOOL_IDS, async () => { + return (await import('@/lib/internal/oci-monitoring/execute-tool')).executeOciMonitoringTool +}) registerFamily(handlerLoaders, CLOUDTRAIL_TOOL_IDS, async () => { return (await import('@/lib/internal/cloudtrail/execute-tool')).executeCloudtrailTool }) diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index 619121cc57f..12611fd9ae1 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -1046,6 +1046,16 @@ export const OAUTH_PROVIDERS: Record = { scopes: [], authType: 'service_account', }, + oci_monitoring: { + name: 'OCI Monitoring', + description: 'Discover metrics, query time series, publish metrics, and manage alarms.', + providerId: 'oci-monitoring', + serviceAccountProviderId: 'oci-api-key-service-account', + icon: NetSuiteIcon, + baseProviderIcon: NetSuiteIcon, + scopes: [], + authType: 'service_account', + }, }, defaultService: 'oci', }, diff --git a/apps/sim/lib/oauth/types.ts b/apps/sim/lib/oauth/types.ts index a8485f58c62..51daa0c148b 100644 --- a/apps/sim/lib/oauth/types.ts +++ b/apps/sim/lib/oauth/types.ts @@ -104,6 +104,7 @@ export type OAuthProvider = export type OAuthService = | 'oci' + | 'oci_monitoring' | 'google' | 'google-email' | 'google-drive' diff --git a/apps/sim/lib/selectors/manifest.test.ts b/apps/sim/lib/selectors/manifest.test.ts index 599b78e1866..0094921cd54 100644 --- a/apps/sim/lib/selectors/manifest.test.ts +++ b/apps/sim/lib/selectors/manifest.test.ts @@ -9,8 +9,8 @@ describe('selector manifest', () => { const count = (classification: (typeof classifications)[number]) => classifications.filter((value) => value === classification).length - expect(Object.keys(selectorManifest)).toHaveLength(95) - expect(count('provider-server')).toBe(82) + expect(Object.keys(selectorManifest)).toHaveLength(98) + expect(count('provider-server')).toBe(85) expect(count('internal-server')).toBe(12) expect(count('local')).toBe(1) expect(classifications).not.toContain('provider-legacy') @@ -36,7 +36,7 @@ describe('selector manifest', () => { const rawConnectionKeys = providerKeys.filter( (key) => !serverSelectorRegistry[key as keyof typeof serverSelectorRegistry].credential ) - expect(providerKeys).toHaveLength(82) + expect(providerKeys).toHaveLength(85) expect(rawConnectionKeys.sort()).toEqual([ 'cloudwatch.logGroups', 'cloudwatch.logStreams', @@ -98,7 +98,7 @@ describe('selector manifest', () => { (attachment) => attachment.destination !== 'fixed' ) - expect(preparedDestinations).toHaveLength(13) + expect(preparedDestinations).toHaveLength(16) for (const attachment of preparedDestinations) { expect(attachment.destination).toEqual( expect.objectContaining({ diff --git a/apps/sim/lib/selectors/manifest.ts b/apps/sim/lib/selectors/manifest.ts index 51ad39a29dd..6871b8f8f2f 100644 --- a/apps/sim/lib/selectors/manifest.ts +++ b/apps/sim/lib/selectors/manifest.ts @@ -179,6 +179,26 @@ export const selectorManifest = { 'microsoft.planner.plans': providerSelector([], { listMode: 'paginated', detail: true }), 'notion.databases': providerSelector([], { detail: true }), 'notion.pages': providerSelector([], { detail: true }), + 'oci_monitoring.namespaces': providerSelector( + ['region', 'compartmentId', 'metricCompartmentId'], + { + readiness: { all: ['oauthCredential'], any: ['compartmentId', 'metricCompartmentId'] }, + listMode: 'paginated', + detail: true, + } + ), + 'oci_monitoring.alarms': providerSelector(['region', 'compartmentId'], { + readiness: { all: ['oauthCredential', 'compartmentId'] }, + listMode: 'paginated', + detail: true, + search: true, + }), + 'oci_monitoring.alarmSuppressions': providerSelector(['region', 'alarmId'], { + readiness: { all: ['oauthCredential', 'alarmId'] }, + listMode: 'paginated', + detail: true, + search: true, + }), 'netsuite.recordTypes': providerSelector(['jobId'], { detail: true, unknownDetail: true, diff --git a/apps/sim/lib/selectors/server/providers/oci-monitoring.test.ts b/apps/sim/lib/selectors/server/providers/oci-monitoring.test.ts new file mode 100644 index 00000000000..79d35ab6827 --- /dev/null +++ b/apps/sim/lib/selectors/server/providers/oci-monitoring.test.ts @@ -0,0 +1,378 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ createClient: vi.fn(), execute: vi.fn() })) +vi.mock('@/lib/internal/oci/client.server', () => ({ createOciClient: mocks.createClient })) +vi.mock('@/lib/internal/oci-monitoring/operations', () => ({ + executeOciMonitoringOperation: mocks.execute, +})) +vi.mock('@/blocks/registry', () => ({ getBlock: vi.fn() })) + +import { OciClientError } from '@/lib/internal/oci/errors' +import { ociMonitoringInputSchemas } from '@/lib/internal/oci-monitoring/input' +import { + buildSelectorContextFromValues, + getSelectorContextSubBlocks, +} from '@/lib/selectors/context' +import { isSelectorReady } from '@/lib/selectors/manifest' +import { createSelectorProtectedValues } from '@/lib/selectors/server/protected-values' +import { ociMonitoringSelectorAttachments } from '@/lib/selectors/server/providers/oci-monitoring' +import type { ExecuteServerSelectorArgs } from '@/lib/selectors/server/types' +import { parseDependsOn } from '@/lib/workflows/subblocks/visibility' +import { OciMonitoringBlock } from '@/blocks/blocks/oci_monitoring' + +const client = { bound: true } +const selectors = ociMonitoringSelectorAttachments + +function args( + selectorKey: ExecuteServerSelectorArgs['selectorKey'] = 'oci_monitoring.namespaces' +): ExecuteServerSelectorArgs { + return { + selectorKey, + context: { + oauthCredential: 'requested-credential', + region: 'us-ashburn-1', + compartmentId: 'compartment', + alarmId: 'alarm', + }, + request: { kind: 'list' }, + scope: { kind: 'workspace', workspaceId: 'workspace' }, + workspaceId: 'workspace', + principal: { kind: 'session', userId: 'user', sessionId: 'session' }, + requesterUserId: 'user', + credential: { + suppliedId: 'requested-credential', + access: { + ok: true, + resolvedCredentialId: 'authorized-credential', + credentialType: 'service_account', + }, + }, + references: new Map(), + protectedValues: createSelectorProtectedValues(), + } +} + +describe('OCI Monitoring selector adapter', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.createClient.mockResolvedValue(client) + }) + + it('clears untouched fields after the shared handler merges block parameters', () => { + const raw = { + operation: 'summarize_metrics_data', + oauthCredential: 'credential', + compartmentId: 'compartment', + namespace: 'my_app', + query: 'Requests[5m].mean()', + region: null, + resourceGroup: null, + startTime: '', + endTime: null, + resolution: '', + maxStreams: '20', + maxDatapoints: '', + compartmentIdInSubtree: 'false', + } + const merged = { ...raw, ...OciMonitoringBlock.tools.config!.params!(raw) } + expect(ociMonitoringInputSchemas.summarize_metrics_data.parse(merged)).toMatchObject({ + region: undefined, + resourceGroup: undefined, + startTime: undefined, + endTime: undefined, + maxStreams: 20, + maxDatapoints: 10000, + compartmentIdInSubtree: false, + query: raw.query, + }) + }) + + it('keeps explicit false, zero and empty collections during block normalization', () => { + const raw = { + operation: 'update_alarm', + oauthCredential: 'credential', + alarmId: 'alarm', + isEnabled: 'false', + freeformTags: {}, + overrides: [], + limit: 0, + displayName: null, + } + const merged = { ...raw, ...OciMonitoringBlock.tools.config!.params!(raw) } + expect(merged).toMatchObject({ + isEnabled: false, + freeformTags: {}, + overrides: [], + limit: 0, + displayName: undefined, + }) + expect(ociMonitoringInputSchemas.update_alarm.parse(merged)).toMatchObject({ + isEnabled: false, + freeformTags: {}, + overrides: [], + displayName: undefined, + }) + }) + + it.each([ + ['oci_monitoring.namespaces', 'list_metrics', { compartmentId: 'compartment' }], + ['oci_monitoring.namespaces', 'create_alarm', { metricCompartmentId: 'metrics-compartment' }], + ['oci_monitoring.alarms', 'get_alarm', { compartmentId: 'compartment' }], + ['oci_monitoring.alarmSuppressions', 'get_alarm_suppression', { alarmIdSelector: 'alarm' }], + ] as const)('permits credential-region defaults for %s during %s', (key, operation, scope) => { + const values = { credential: 'credential', operation, ...scope } + const selector = OciMonitoringBlock.subBlocks.find((config) => config.selectorKey === key)! + const dependencies = parseDependsOn(selector.dependsOn) + const context = buildSelectorContextFromValues({ + selectorKey: key, + contextConfigs: getSelectorContextSubBlocks(OciMonitoringBlock.subBlocks, values, false), + values, + dependsOn: dependencies.allDependsOnFields, + }) + + const contextValues: Record = context + expect(dependencies.allFields.every((field) => Boolean(contextValues[field]))).toBe(true) + expect(dependencies.anyFields.some((field) => Boolean(contextValues[field]))).toBe(true) + expect(isSelectorReady(key, context)).toBe(true) + expect(context.region).toBeUndefined() + }) + + it('uses the credential region when an override is omitted', async () => { + mocks.execute.mockResolvedValue({ success: true, output: { metrics: [] } }) + const call = args() + call.context.region = undefined + await selectors['oci_monitoring.namespaces'].execute(call) + expect(mocks.createClient).toHaveBeenCalledWith( + expect.objectContaining({ + credentialId: 'authorized-credential', + region: undefined, + }) + ) + }) + + it('uses the shared authorization result and groups metric discovery by namespace', async () => { + mocks.execute.mockResolvedValue({ + success: true, + output: { metrics: [{ namespace: 'oci_computeagent' }, { namespace: 'oci_computeagent' }] }, + }) + const call = args() + call.context.metricCompartmentId = 'metrics-compartment' + + await expect(selectors['oci_monitoring.namespaces'].execute(call)).resolves.toEqual({ + kind: 'list', + items: [{ id: 'oci_computeagent', label: 'oci_computeagent' }], + }) + expect(mocks.createClient).toHaveBeenCalledWith({ + credentialId: 'authorized-credential', + workspaceId: 'workspace', + serviceId: 'oci_monitoring', + region: 'us-ashburn-1', + }) + expect(mocks.execute).toHaveBeenCalledWith( + client, + 'list_metrics', + expect.objectContaining({ + compartmentId: 'metrics-compartment', + groupBy: ['namespace'], + limit: 100, + }), + undefined + ) + }) + + it.each([ + ['oci_monitoring.namespaces', 'metrics'], + ['oci_monitoring.alarms', 'alarms'], + ['oci_monitoring.alarmSuppressions', 'alarmSuppressions'], + ] as const)('preserves empty-page cursors for %s', async (key, field) => { + mocks.execute.mockResolvedValue({ + success: true, + output: { [field]: [], nextPage: 'next::+=/' }, + }) + const call = args(key) + call.request = { kind: 'list', cursor: 'previous::+=/' } + + await expect(selectors[key].execute(call, client)).resolves.toEqual({ + kind: 'list', + items: [], + nextCursor: 'next::+=/', + }) + expect(mocks.execute).toHaveBeenCalledWith( + client, + expect.any(String), + expect.objectContaining({ page: 'previous::+=/' }), + undefined + ) + expect(mocks.execute).toHaveBeenCalledTimes(1) + }) + + it.each([ + ['oci_monitoring.alarms', 'alarms', 'list_alarms', { compartmentId: 'compartment' }], + [ + 'oci_monitoring.alarmSuppressions', + 'alarmSuppressions', + 'list_alarm_suppressions', + { alarmId: 'alarm' }, + ], + ] as const)( + 'projects safe options and exact-name search for %s', + async (key, field, operation, scope) => { + mocks.execute.mockResolvedValue({ + success: true, + output: { [field]: [{ id: 'selected', displayName: 'CPU', body: 'private' }] }, + }) + const call = args(key) + call.request = { kind: 'list', search: 'CPU' } + + await expect(selectors[key].execute(call, client)).resolves.toEqual({ + kind: 'list', + items: [{ id: 'selected', label: 'CPU' }], + }) + expect(mocks.execute).toHaveBeenCalledWith( + client, + operation, + expect.objectContaining({ ...scope, displayName: 'CPU' }), + undefined + ) + } + ) + + it('returns null when a namespace no longer exists', async () => { + mocks.execute.mockResolvedValue({ success: true, output: { metrics: [] } }) + const call = args() + call.request = { kind: 'detail', id: 'missing' } + await expect(selectors['oci_monitoring.namespaces'].execute(call, client)).resolves.toEqual({ + kind: 'detail', + item: null, + }) + expect(mocks.execute.mock.calls[0][2]).toMatchObject({ + namespace: 'missing', + groupBy: ['namespace'], + }) + }) + + it.each(['oci_monitoring.alarms', 'oci_monitoring.alarmSuppressions'] as const)( + 'returns null for deleted selections in %s', + async (key) => { + mocks.execute.mockRejectedValueOnce(new OciClientError('request_failed', { status: 404 })) + const call = args(key) + call.request = { kind: 'detail', id: 'missing' } + await expect(selectors[key].execute(call, client)).resolves.toEqual({ + kind: 'detail', + item: null, + }) + } + ) + + it('does not retain an alarm selection from another compartment', async () => { + mocks.execute.mockResolvedValue({ + success: true, + output: { alarm: { id: 'old', displayName: 'Old', compartmentId: 'other' } }, + }) + const call = args('oci_monitoring.alarms') + call.request = { kind: 'detail', id: 'old' } + await expect(selectors['oci_monitoring.alarms'].execute(call, client)).resolves.toEqual({ + kind: 'detail', + item: null, + }) + }) + + it('does not retain a suppression selection from another alarm', async () => { + mocks.execute.mockResolvedValue({ + success: true, + output: { + alarmSuppression: { + id: 'old', + displayName: 'Old', + alarmSuppressionTarget: { targetType: 'ALARM', alarmId: 'other' }, + }, + }, + }) + const call = args('oci_monitoring.alarmSuppressions') + call.request = { kind: 'detail', id: 'old' } + await expect( + selectors['oci_monitoring.alarmSuppressions'].execute(call, client) + ).resolves.toEqual({ + kind: 'detail', + item: null, + }) + }) + + it('projects a matching alarm selection', async () => { + mocks.execute.mockResolvedValue({ + success: true, + output: { alarm: { id: 'alarm', displayName: 'CPU', compartmentId: 'compartment' } }, + }) + const call = args('oci_monitoring.alarms') + call.request = { kind: 'detail', id: 'alarm' } + await expect(selectors['oci_monitoring.alarms'].execute(call, client)).resolves.toEqual({ + kind: 'detail', + item: { id: 'alarm', label: 'CPU' }, + }) + }) + + it('requires prepared credential authorization', async () => { + const call = args() + call.credential = undefined + await expect(selectors['oci_monitoring.namespaces'].execute(call)).rejects.toMatchObject({ + name: 'SelectorConnectionUnavailableError', + }) + expect(mocks.createClient).not.toHaveBeenCalled() + expect(mocks.execute).not.toHaveBeenCalled() + }) + + it('rejects incomplete context without calling a provider operation', async () => { + const call = args() + call.context = { oauthCredential: 'credential' } + await expect( + selectors['oci_monitoring.namespaces'].execute(call, client) + ).rejects.toMatchObject({ + name: 'SelectorContextUnavailableError', + }) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + it.each([ + [401, 'SelectorConnectionUnavailableError', 401], + [403, 'SelectorConnectionUnavailableError', 403], + [429, 'SelectorOptionsUnavailableError', 429], + [500, 'SelectorOptionsUnavailableError', 502], + ] as const)('maps trusted status %i to a safe error', async (status, name, safeStatus) => { + mocks.execute.mockRejectedValueOnce(new OciClientError('request_failed', { status })) + await expect( + selectors['oci_monitoring.namespaces'].execute(args(), client) + ).rejects.toMatchObject({ + name, + status: safeStatus, + }) + }) + + it('does not trust status-shaped errors or expose provider data', async () => { + mocks.execute.mockRejectedValueOnce({ status: 401, message: 'provider-secret-canary' }) + await expect( + selectors['oci_monitoring.namespaces'].execute(args(), client) + ).rejects.toMatchObject({ + name: 'SelectorOptionsUnavailableError', + status: 502, + }) + }) + + it('preserves cancellation during a listing', async () => { + const controller = new AbortController() + const call = args() + call.signal = controller.signal + mocks.execute.mockImplementationOnce(async () => { + controller.abort() + throw new Error('interrupted') + }) + await expect( + selectors['oci_monitoring.namespaces'].execute(call, client) + ).rejects.toMatchObject({ + name: 'AbortError', + }) + expect(mocks.execute.mock.calls[0][3]).toBe(controller.signal) + }) +}) diff --git a/apps/sim/lib/selectors/server/providers/oci-monitoring.ts b/apps/sim/lib/selectors/server/providers/oci-monitoring.ts new file mode 100644 index 00000000000..98e321541f8 --- /dev/null +++ b/apps/sim/lib/selectors/server/providers/oci-monitoring.ts @@ -0,0 +1,178 @@ +import { z } from 'zod' +import { createOciClient, type OciClient } from '@/lib/internal/oci/client.server' +import { OciClientError } from '@/lib/internal/oci/errors' +import { + type OciMonitoringOperation, + ociMonitoringInputSchemas, +} from '@/lib/internal/oci-monitoring/input' +import { executeOciMonitoringOperation } from '@/lib/internal/oci-monitoring/operations' +import { + SelectorConnectionUnavailableError, + SelectorContextUnavailableError, + SelectorOptionsUnavailableError, +} from '@/lib/selectors/server/errors' +import { + definePreparedSelectorAttachment, + detailSelectorResult, + type ExecuteServerSelectorArgs, + listSelectorResult, + type ServerSelectorAttachmentMap, +} from '@/lib/selectors/server/types' + +type MonitoringSelectorKey = + | 'oci_monitoring.namespaces' + | 'oci_monitoring.alarms' + | 'oci_monitoring.alarmSuppressions' + +const credential = { + kind: 'stored', + field: 'oauthCredential', + serviceIds: ['oci_monitoring'], +} as const +const integrationBlockTypes = ['oci_monitoring'] as const +const optionSchema = z.object({ id: z.string().min(1), displayName: z.string() }) + +function selectorError(error: unknown): Error { + if ( + error instanceof OciClientError && + (error.code === 'credential_unavailable' || error.status === 401 || error.status === 403) + ) { + return new SelectorConnectionUnavailableError(error.status === 401 ? 401 : 403) + } + return new SelectorOptionsUnavailableError( + error instanceof OciClientError && error.status === 429 ? 429 : 502 + ) +} + +async function prepareDestination(args: ExecuteServerSelectorArgs): Promise { + args.signal?.throwIfAborted() + const access = args.credential?.access + if (!access?.ok || !access.resolvedCredentialId || access.credentialType !== 'service_account') { + throw new SelectorConnectionUnavailableError() + } + try { + return await createOciClient({ + credentialId: access.resolvedCredentialId, + workspaceId: args.workspaceId, + serviceId: 'oci_monitoring', + region: args.context.region, + }) + } catch (error) { + args.signal?.throwIfAborted() + throw selectorError(error) + } +} + +async function executeSelector(args: ExecuteServerSelectorArgs, client: OciClient) { + args.signal?.throwIfAborted() + const namespace = args.selectorKey === 'oci_monitoring.namespaces' + const alarms = args.selectorKey === 'oci_monitoring.alarms' + const detail = args.request.kind === 'detail' + const compartmentId = namespace + ? args.context.metricCompartmentId || args.context.compartmentId + : args.context.compartmentId + const operation: OciMonitoringOperation = namespace + ? 'list_metrics' + : alarms + ? detail + ? 'get_alarm' + : 'list_alarms' + : detail + ? 'get_alarm_suppression' + : 'list_alarm_suppressions' + const input = { + oauthCredential: args.credential?.access?.resolvedCredentialId, + region: args.context.region, + compartmentId, + limit: 100, + ...(args.request.kind === 'list' + ? { page: args.request.cursor, displayName: args.request.search || undefined } + : {}), + ...(namespace + ? { + groupBy: ['namespace'], + namespace: args.request.kind === 'detail' ? args.request.id : undefined, + } + : {}), + alarmId: alarms && args.request.kind === 'detail' ? args.request.id : args.context.alarmId, + alarmSuppressionId: + !namespace && !alarms && args.request.kind === 'detail' ? args.request.id : undefined, + } + const parsed = ociMonitoringInputSchemas[operation].safeParse(input) + if (!parsed.success) throw new SelectorContextUnavailableError() + try { + const result = await executeOciMonitoringOperation(client, operation, parsed.data, args.signal) + if (!result.success) throw new SelectorOptionsUnavailableError() + const output = result.output + if (namespace) { + const metrics = z.array(z.object({ namespace: z.string() })).parse(output.metrics) + const options = [...new Set(metrics.map((metric) => metric.namespace))].map((name) => ({ + id: name, + label: name, + })) + if (args.request.kind === 'detail') { + const selectedId = args.request.id + return detailSelectorResult(options.find((option) => option.id === selectedId) ?? null) + } + return listSelectorResult( + options, + typeof output.nextPage === 'string' ? output.nextPage : undefined + ) + } + if (detail) { + if (alarms) { + const alarm = z.object({ compartmentId: z.string() }).parse(output.alarm) + if (alarm.compartmentId !== compartmentId) return detailSelectorResult(null) + } else { + const suppression = z + .object({ + alarmSuppressionTarget: z.object({ + targetType: z.string(), + alarmId: z.string().optional(), + }), + }) + .parse(output.alarmSuppression) + if ( + suppression.alarmSuppressionTarget.targetType !== 'ALARM' || + suppression.alarmSuppressionTarget.alarmId !== args.context.alarmId + ) { + return detailSelectorResult(null) + } + } + const item = optionSchema.parse(alarms ? output.alarm : output.alarmSuppression) + return detailSelectorResult({ id: item.id, label: item.displayName }) + } + const items = z.array(optionSchema).parse(alarms ? output.alarms : output.alarmSuppressions) + return listSelectorResult( + items.map((item) => ({ id: item.id, label: item.displayName })), + typeof output.nextPage === 'string' ? output.nextPage : undefined + ) + } catch (error) { + args.signal?.throwIfAborted() + if (detail && error instanceof OciClientError && error.status === 404) { + return detailSelectorResult(null) + } + throw selectorError(error) + } +} + +export const ociMonitoringSelectorAttachments = { + 'oci_monitoring.namespaces': definePreparedSelectorAttachment({ + credential, + integrationBlockTypes, + destination: { kind: 'credential-bound', prepare: prepareDestination }, + execute: executeSelector, + }), + 'oci_monitoring.alarms': definePreparedSelectorAttachment({ + credential, + integrationBlockTypes, + destination: { kind: 'credential-bound', prepare: prepareDestination }, + execute: executeSelector, + }), + 'oci_monitoring.alarmSuppressions': definePreparedSelectorAttachment({ + credential, + integrationBlockTypes, + destination: { kind: 'credential-bound', prepare: prepareDestination }, + execute: executeSelector, + }), +} satisfies ServerSelectorAttachmentMap diff --git a/apps/sim/lib/selectors/server/registry.ts b/apps/sim/lib/selectors/server/registry.ts index ef9a815f1af..3ce7c1ed46c 100644 --- a/apps/sim/lib/selectors/server/registry.ts +++ b/apps/sim/lib/selectors/server/registry.ts @@ -21,6 +21,7 @@ import { microsoftSelectorAttachments } from '@/lib/selectors/server/providers/m import { mondaySelectorAttachments } from '@/lib/selectors/server/providers/monday' import { netsuiteSelectorAttachments } from '@/lib/selectors/server/providers/netsuite' import { notionSelectorAttachments } from '@/lib/selectors/server/providers/notion' +import { ociMonitoringSelectorAttachments } from '@/lib/selectors/server/providers/oci-monitoring' import { pipedriveSelectorAttachments } from '@/lib/selectors/server/providers/pipedrive' import { sharepointSelectorAttachments } from '@/lib/selectors/server/providers/sharepoint' import { slackSelectorAttachments } from '@/lib/selectors/server/providers/slack' @@ -55,6 +56,7 @@ export const serverSelectorRegistry = { ...mondaySelectorAttachments, ...netsuiteSelectorAttachments, ...notionSelectorAttachments, + ...ociMonitoringSelectorAttachments, ...pipedriveSelectorAttachments, ...sharepointSelectorAttachments, ...slackSelectorAttachments, diff --git a/apps/sim/lib/selectors/types.ts b/apps/sim/lib/selectors/types.ts index ad644b8946b..7de94a8c810 100644 --- a/apps/sim/lib/selectors/types.ts +++ b/apps/sim/lib/selectors/types.ts @@ -2,6 +2,10 @@ import type { ComponentType } from 'react' export const selectorContextKeys = [ 'oauthCredential', + 'region', + 'compartmentId', + 'metricCompartmentId', + 'alarmId', 'domain', 'teamId', 'projectId', diff --git a/apps/sim/tools/generated/tool-ids.ts b/apps/sim/tools/generated/tool-ids.ts index e4f6f681ab3..0649da2426c 100644 --- a/apps/sim/tools/generated/tool-ids.ts +++ b/apps/sim/tools/generated/tool-ids.ts @@ -3,7 +3,7 @@ /** Every registered tool id, including versioned variants. */ const toolIds: string[] = JSON.parse( - '["a2a_cancel_task","a2a_get_agent_card","a2a_get_task","a2a_send_message","affinity_batch_update_entity_fields","affinity_batch_update_list_entry_fields","affinity_create_list","affinity_create_list_field_dropdown_option","affinity_create_merge","affinity_create_note","affinity_create_reminder","affinity_delete_list_field_dropdown_option","affinity_delete_note","affinity_get_company","affinity_get_current_user","affinity_get_entity_field_value","affinity_get_list","affinity_get_list_entry","affinity_get_list_entry_field","affinity_get_list_field_dropdown_option","affinity_get_merge","affinity_get_merge_task","affinity_get_note","affinity_get_opportunity","affinity_get_person","affinity_get_saved_view","affinity_get_transcript","affinity_get_user","affinity_list_calls","affinity_list_chat_messages","affinity_list_companies","affinity_list_coworker_connections","affinity_list_emails","affinity_list_entity_field_values","affinity_list_entity_list_entries","affinity_list_entity_lists","affinity_list_entity_notes","affinity_list_entity_relationships","affinity_list_field_dropdown_options","affinity_list_field_metadata","affinity_list_field_value_changes","affinity_list_investor_executive_connections","affinity_list_list_entries","affinity_list_list_entry_field_value_changes","affinity_list_list_entry_fields","affinity_list_list_field_dropdown_options","affinity_list_list_fields","affinity_list_lists","affinity_list_meetings","affinity_list_merge_tasks","affinity_list_merges","affinity_list_note_attached_companies","affinity_list_note_attached_opportunities","affinity_list_note_attached_persons","affinity_list_note_replies","affinity_list_notes","affinity_list_opportunities","affinity_list_persons","affinity_list_reminders","affinity_list_saved_view_entries","affinity_list_saved_views","affinity_list_transcript_fragments","affinity_list_transcripts","affinity_list_users","affinity_search_companies","affinity_search_files","affinity_search_list_entries","affinity_search_notes","affinity_search_persons","affinity_semantic_search","affinity_update_entity_field_value","affinity_update_list_entry_field","affinity_update_list_field_dropdown_option","affinity_update_note","agentmail_create_draft","agentmail_create_inbox","agentmail_delete_draft","agentmail_delete_inbox","agentmail_delete_thread","agentmail_forward_message","agentmail_get_draft","agentmail_get_inbox","agentmail_get_message","agentmail_get_thread","agentmail_list_drafts","agentmail_list_inboxes","agentmail_list_messages","agentmail_list_threads","agentmail_reply_message","agentmail_send_draft","agentmail_send_message","agentmail_update_draft","agentmail_update_inbox","agentmail_update_message","agentmail_update_thread","agentphone_create_call","agentphone_create_contact","agentphone_create_number","agentphone_delete_contact","agentphone_get_call","agentphone_get_call_transcript","agentphone_get_contact","agentphone_get_conversation","agentphone_get_conversation_messages","agentphone_get_number_messages","agentphone_get_usage","agentphone_get_usage_daily","agentphone_get_usage_monthly","agentphone_list_calls","agentphone_list_contacts","agentphone_list_conversations","agentphone_list_numbers","agentphone_react_to_message","agentphone_release_number","agentphone_send_message","agentphone_update_contact","agentphone_update_conversation","agiloft_async_status","agiloft_attach_file","agiloft_attachment_info","agiloft_create_record","agiloft_delete_record","agiloft_get_choice_line_id","agiloft_list_tables","agiloft_lock_record","agiloft_nlp_search","agiloft_read_record","agiloft_remove_attachment","agiloft_retrieve_attachment","agiloft_run_action_button","agiloft_saved_search","agiloft_search_records","agiloft_select_records","agiloft_update_record","agiloft_upsert_record","ahrefs_anchors","ahrefs_backlinks","ahrefs_backlinks_stats","ahrefs_batch_analysis","ahrefs_broken_backlinks","ahrefs_domain_rating","ahrefs_domain_rating_history","ahrefs_keyword_overview","ahrefs_keywords_history","ahrefs_metrics","ahrefs_metrics_history","ahrefs_organic_competitors","ahrefs_organic_keywords","ahrefs_paid_pages","ahrefs_rank_tracker_competitors_overview","ahrefs_rank_tracker_competitors_stats","ahrefs_rank_tracker_overview","ahrefs_rank_tracker_serp_overview","ahrefs_refdomains_history","ahrefs_referring_domains","ahrefs_related_terms","ahrefs_site_audit_page_explorer","ahrefs_top_pages","airtable_create_records","airtable_delete_records","airtable_get_base_schema","airtable_get_record","airtable_list_bases","airtable_list_records","airtable_list_tables","airtable_update_multiple_records","airtable_update_record","airtable_upsert_records","airweave_search","algolia_add_record","algolia_batch_operations","algolia_browse_records","algolia_clear_records","algolia_copy_move_index","algolia_delete_by_filter","algolia_delete_index","algolia_delete_record","algolia_get_record","algolia_get_records","algolia_get_settings","algolia_get_task_status","algolia_list_indices","algolia_partial_update_record","algolia_search","algolia_update_settings","amplitude_event_segmentation","amplitude_funnels","amplitude_get_active_users","amplitude_get_revenue","amplitude_group_identify","amplitude_identify_user","amplitude_list_events","amplitude_realtime_active_users","amplitude_retention","amplitude_send_event","amplitude_user_activity","amplitude_user_profile","amplitude_user_search","apify_get_dataset_items","apify_get_run","apify_run_actor_async","apify_run_actor_sync","apify_run_task","apollo_account_bulk_create","apollo_account_bulk_update","apollo_account_create","apollo_account_search","apollo_account_update","apollo_contact_bulk_create","apollo_contact_bulk_update","apollo_contact_create","apollo_contact_search","apollo_contact_update","apollo_email_accounts","apollo_opportunity_create","apollo_opportunity_get","apollo_opportunity_search","apollo_opportunity_update","apollo_organization_bulk_enrich","apollo_organization_enrich","apollo_organization_search","apollo_people_bulk_enrich","apollo_people_enrich","apollo_people_search","apollo_sequence_add_contacts","apollo_sequence_search","apollo_task_create","apollo_task_search","appconfig_create_application","appconfig_create_configuration_profile","appconfig_create_environment","appconfig_create_hosted_configuration_version","appconfig_delete_application","appconfig_delete_configuration_profile","appconfig_delete_environment","appconfig_delete_hosted_configuration_version","appconfig_get_application","appconfig_get_configuration","appconfig_get_configuration_profile","appconfig_get_deployment","appconfig_get_environment","appconfig_get_hosted_configuration_version","appconfig_list_applications","appconfig_list_configuration_profiles","appconfig_list_deployment_strategies","appconfig_list_deployments","appconfig_list_environments","appconfig_list_hosted_configuration_versions","appconfig_start_deployment","appconfig_stop_deployment","appconfig_update_application","appconfig_update_configuration_profile","appconfig_update_environment","arxiv_get_author_papers","arxiv_get_paper","arxiv_search","asana_add_comment","asana_add_followers","asana_create_project","asana_create_section","asana_create_subtask","asana_create_task","asana_delete_task","asana_get_project","asana_get_projects","asana_get_task","asana_list_sections","asana_list_workspaces","asana_search_tasks","asana_update_task","ashby_add_candidate_tag","ashby_anonymize_candidate","ashby_change_application_source","ashby_change_application_stage","ashby_create_application","ashby_create_candidate","ashby_create_note","ashby_delete_application","ashby_get_application","ashby_get_candidate","ashby_get_job","ashby_get_job_posting","ashby_get_offer","ashby_get_opening","ashby_list_application_feedback","ashby_list_application_history","ashby_list_applications","ashby_list_archive_reasons","ashby_list_candidate_tags","ashby_list_candidates","ashby_list_custom_fields","ashby_list_departments","ashby_list_interview_plans","ashby_list_interview_stages","ashby_list_interviews","ashby_list_job_postings","ashby_list_jobs","ashby_list_locations","ashby_list_notes","ashby_list_offers","ashby_list_openings","ashby_list_sources","ashby_list_users","ashby_remove_candidate_tag","ashby_search_candidates","ashby_search_jobs","ashby_search_openings","ashby_search_users","ashby_set_custom_field_value","ashby_set_custom_field_values","ashby_transfer_application","ashby_update_candidate","ashby_upload_candidate_file","ashby_upload_resume","athena_batch_get_query_execution","athena_create_named_query","athena_delete_named_query","athena_get_named_query","athena_get_query_execution","athena_get_query_results","athena_list_databases","athena_list_named_queries","athena_list_query_executions","athena_list_table_metadata","athena_start_query","athena_stop_query","attio_assert_record","attio_create_attribute","attio_create_comment","attio_create_list","attio_create_list_entry","attio_create_note","attio_create_object","attio_create_record","attio_create_task","attio_create_webhook","attio_delete_comment","attio_delete_list_entry","attio_delete_note","attio_delete_record","attio_delete_task","attio_delete_webhook","attio_get_attribute","attio_get_comment","attio_get_list","attio_get_list_entry","attio_get_member","attio_get_note","attio_get_object","attio_get_record","attio_get_task","attio_get_thread","attio_get_webhook","attio_list_attributes","attio_list_lists","attio_list_members","attio_list_notes","attio_list_objects","attio_list_records","attio_list_tasks","attio_list_threads","attio_list_webhooks","attio_query_list_entries","attio_search_records","attio_update_attribute","attio_update_list","attio_update_list_entry","attio_update_object","attio_update_record","attio_update_task","attio_update_webhook","azure_data_explorer_create_table","azure_data_explorer_drop_table","azure_data_explorer_ingest_from_query","azure_data_explorer_ingest_inline","azure_data_explorer_list_databases","azure_data_explorer_list_functions","azure_data_explorer_list_tables","azure_data_explorer_management","azure_data_explorer_query","azure_data_explorer_show_database_schema","azure_data_explorer_show_ingestion_failures","azure_data_explorer_show_operations","azure_data_explorer_show_table_details","azure_data_explorer_show_table_schema","azure_devops_add_comment","azure_devops_create_work_item","azure_devops_get_build_log","azure_devops_get_build_timeline","azure_devops_get_comments","azure_devops_get_pipeline","azure_devops_get_pipeline_run","azure_devops_get_work_item","azure_devops_get_work_items_batch","azure_devops_get_work_items_between_builds","azure_devops_list_build_logs","azure_devops_list_builds","azure_devops_list_pipeline_runs","azure_devops_list_pipelines","azure_devops_query_work_items","azure_devops_update_work_item","bitbucket_approve_pull_request","bitbucket_create_branch","bitbucket_create_pull_request","bitbucket_create_pull_request_comment","bitbucket_decline_pull_request","bitbucket_delete_branch","bitbucket_get_commit","bitbucket_get_file","bitbucket_get_file_metadata","bitbucket_get_pipeline","bitbucket_get_pipeline_step_log","bitbucket_get_pull_request","bitbucket_get_pull_request_diff","bitbucket_get_pull_request_diffstat","bitbucket_get_pull_request_merge_task_status","bitbucket_get_repository","bitbucket_list_branches","bitbucket_list_commits","bitbucket_list_directory","bitbucket_list_pipeline_steps","bitbucket_list_pipelines","bitbucket_list_pull_request_comments","bitbucket_list_pull_request_commit_statuses","bitbucket_list_pull_requests","bitbucket_list_repositories","bitbucket_list_workspaces","bitbucket_merge_pull_request","bitbucket_request_pull_request_changes","bitbucket_stop_pipeline","bitbucket_trigger_pipeline","box_copy_file","box_create_folder","box_delete_file","box_delete_folder","box_download_file","box_get_file_info","box_list_folder_items","box_search","box_sign_cancel_request","box_sign_create_request","box_sign_get_request","box_sign_list_requests","box_sign_resend_request","box_update_file","box_upload_file","brandfetch_get_brand","brandfetch_search","brex_archive_budget","brex_create_budget","brex_create_spend_limit","brex_create_transfer","brex_create_vendor","brex_get_budget","brex_get_cash_account","brex_get_company","brex_get_current_user","brex_get_expense","brex_get_spend_limit","brex_get_transfer","brex_get_user","brex_get_vendor","brex_list_budgets","brex_list_card_accounts","brex_list_card_statements","brex_list_card_transactions","brex_list_cards","brex_list_cash_accounts","brex_list_cash_statements","brex_list_cash_transactions","brex_list_departments","brex_list_expenses","brex_list_locations","brex_list_spend_limits","brex_list_titles","brex_list_transfers","brex_list_users","brex_list_vendors","brex_match_receipt","brex_update_expense","brex_update_vendor","brex_upload_receipt","brightdata_cancel_snapshot","brightdata_discover","brightdata_download_snapshot","brightdata_scrape_dataset","brightdata_scrape_url","brightdata_serp_search","brightdata_snapshot_status","brightdata_sync_scrape","browser_use_run_task","buffer_create_idea","buffer_create_post","buffer_delete_post","buffer_edit_post","buffer_get_account","buffer_get_channels","buffer_get_idea_groups","buffer_get_ideas","buffer_get_post","buffer_get_posts","calcom_cancel_booking","calcom_confirm_booking","calcom_create_booking","calcom_create_event_type","calcom_create_schedule","calcom_decline_booking","calcom_delete_event_type","calcom_delete_schedule","calcom_get_booking","calcom_get_default_schedule","calcom_get_event_type","calcom_get_schedule","calcom_get_slots","calcom_list_bookings","calcom_list_event_types","calcom_list_schedules","calcom_reschedule_booking","calcom_update_event_type","calcom_update_schedule","calendly_cancel_event","calendly_create_event_invitee","calendly_create_invitee_no_show","calendly_create_scheduling_link","calendly_create_webhook","calendly_delete_invitee_no_show","calendly_delete_webhook","calendly_get_current_user","calendly_get_event_invitee","calendly_get_event_type","calendly_get_scheduled_event","calendly_get_user","calendly_list_event_invitees","calendly_list_event_type_available_times","calendly_list_event_types","calendly_list_organization_memberships","calendly_list_routing_form_submissions","calendly_list_routing_forms","calendly_list_scheduled_events","calendly_list_user_availability_schedules","calendly_list_user_busy_times","calendly_list_webhooks","cbinsights_chat","cbinsights_get_commercial_maturity_history","cbinsights_get_exit_probability_history","cbinsights_get_mosaic_history","cbinsights_get_org_business_relationships","cbinsights_get_org_funding_window","cbinsights_get_org_fundings","cbinsights_get_org_investments","cbinsights_get_org_management_and_board","cbinsights_get_org_outlook","cbinsights_get_org_portfolio_exits","cbinsights_get_org_revenue","cbinsights_get_scouting_report","cbinsights_get_strategy_map","cbinsights_list_business_relationships","cbinsights_list_funding_window","cbinsights_list_fundings","cbinsights_list_investments","cbinsights_list_management_and_board","cbinsights_list_outlook","cbinsights_list_portfolio_exits","cbinsights_list_revenue","cbinsights_lookup_organizations","cbinsights_rag","cbinsights_search_firmographics","circleback_add_tag_to_meetings","circleback_create_tag","circleback_delete_action_item","circleback_delete_meeting","circleback_delete_tag","circleback_get_company","circleback_get_meeting","circleback_get_person","circleback_get_transcript","circleback_list_action_items","circleback_list_calendar_events","circleback_list_companies","circleback_list_meetings","circleback_list_people","circleback_list_tags","circleback_remove_tag_from_meetings","circleback_search_meetings","circleback_update_action_item","circleback_update_meeting","circleback_update_tag","clay_populate","clerk_add_organization_member","clerk_ban_user","clerk_create_actor_token","clerk_create_allowlist_identifier","clerk_create_blocklist_identifier","clerk_create_organization","clerk_create_organization_invitation","clerk_create_user","clerk_delete_allowlist_identifier","clerk_delete_blocklist_identifier","clerk_delete_organization","clerk_delete_user","clerk_get_jwt_template","clerk_get_organization","clerk_get_session","clerk_get_user","clerk_get_user_oauth_token","clerk_list_allowlist_identifiers","clerk_list_blocklist_identifiers","clerk_list_jwt_templates","clerk_list_organization_invitations","clerk_list_organization_memberships","clerk_list_organizations","clerk_list_sessions","clerk_list_users","clerk_lock_user","clerk_remove_organization_member","clerk_revoke_actor_token","clerk_revoke_session","clerk_unban_user","clerk_unlock_user","clerk_update_organization","clerk_update_organization_membership","clerk_update_user","clickhouse_count_rows","clickhouse_create_database","clickhouse_create_table","clickhouse_delete","clickhouse_describe_table","clickhouse_drop_database","clickhouse_drop_partition","clickhouse_drop_table","clickhouse_execute","clickhouse_insert","clickhouse_insert_rows","clickhouse_introspect","clickhouse_kill_query","clickhouse_list_clusters","clickhouse_list_databases","clickhouse_list_mutations","clickhouse_list_partitions","clickhouse_list_running_queries","clickhouse_list_tables","clickhouse_optimize_table","clickhouse_query","clickhouse_rename_table","clickhouse_show_create_table","clickhouse_table_stats","clickhouse_truncate_table","clickhouse_update","clickup_add_tag_to_task","clickup_create_checklist","clickup_create_checklist_item","clickup_create_comment","clickup_create_folder","clickup_create_list","clickup_create_task","clickup_create_time_entry","clickup_delete_checklist","clickup_delete_checklist_item","clickup_delete_comment","clickup_delete_task","clickup_delete_time_entry","clickup_get_comments","clickup_get_custom_fields","clickup_get_folders","clickup_get_list_members","clickup_get_lists","clickup_get_running_timer","clickup_get_space_tags","clickup_get_spaces","clickup_get_task","clickup_get_task_members","clickup_get_tasks","clickup_get_time_entries","clickup_get_workspaces","clickup_remove_custom_field_value","clickup_remove_tag_from_task","clickup_search_tasks","clickup_set_custom_field_value","clickup_start_timer","clickup_stop_timer","clickup_update_checklist","clickup_update_checklist_item","clickup_update_comment","clickup_update_task","clickup_update_time_entry","clickup_upload_attachment","cloudflare_create_access_application","cloudflare_create_access_policy","cloudflare_create_access_service_token","cloudflare_create_dns_record","cloudflare_create_r2_bucket","cloudflare_create_rate_limit_rule","cloudflare_create_ruleset","cloudflare_create_ruleset_rule","cloudflare_create_zone","cloudflare_delete_access_application","cloudflare_delete_access_policy","cloudflare_delete_dns_record","cloudflare_delete_r2_bucket","cloudflare_delete_ruleset_rule","cloudflare_delete_zone","cloudflare_dns_analytics","cloudflare_get_access_application","cloudflare_get_r2_bucket","cloudflare_get_ruleset","cloudflare_get_ruleset_entrypoint","cloudflare_get_tunnel","cloudflare_get_tunnel_configuration","cloudflare_get_worker_script_settings","cloudflare_get_zone","cloudflare_get_zone_settings","cloudflare_list_access_applications","cloudflare_list_access_groups","cloudflare_list_access_identity_providers","cloudflare_list_access_policies","cloudflare_list_access_service_tokens","cloudflare_list_certificates","cloudflare_list_dns_records","cloudflare_list_managed_ruleset_overrides","cloudflare_list_r2_buckets","cloudflare_list_rate_limit_rules","cloudflare_list_rulesets","cloudflare_list_tunnels","cloudflare_list_worker_routes","cloudflare_list_worker_scripts","cloudflare_list_zones","cloudflare_purge_cache","cloudflare_revoke_access_service_token","cloudflare_update_access_application","cloudflare_update_access_policy","cloudflare_update_dns_record","cloudflare_update_rate_limit_rule","cloudflare_update_ruleset_rule","cloudflare_update_zone_setting","cloudformation_cancel_update_stack","cloudformation_create_change_set","cloudformation_create_stack","cloudformation_delete_stack","cloudformation_describe_change_set","cloudformation_describe_stack_drift_detection_status","cloudformation_describe_stack_events","cloudformation_describe_stacks","cloudformation_detect_stack_drift","cloudformation_execute_change_set","cloudformation_get_template","cloudformation_get_template_summary","cloudformation_list_stack_resources","cloudformation_update_stack","cloudformation_validate_template","cloudtrail_cancel_query","cloudtrail_describe_query","cloudtrail_describe_trails","cloudtrail_get_event_data_store","cloudtrail_get_event_selectors","cloudtrail_get_insight_selectors","cloudtrail_get_query_results","cloudtrail_get_trail","cloudtrail_get_trail_status","cloudtrail_list_event_data_stores","cloudtrail_list_tags","cloudtrail_list_trails","cloudtrail_lookup_events","cloudtrail_start_query","cloudwatch_describe_alarm_history","cloudwatch_describe_alarms","cloudwatch_describe_log_groups","cloudwatch_describe_log_streams","cloudwatch_filter_log_events","cloudwatch_get_log_events","cloudwatch_get_metric_statistics","cloudwatch_list_metrics","cloudwatch_mute_alarm","cloudwatch_put_log_group_retention","cloudwatch_put_metric_data","cloudwatch_query_logs","cloudwatch_unmute_alarm","codepipeline_disable_stage_transition","codepipeline_enable_stage_transition","codepipeline_get_pipeline","codepipeline_get_pipeline_execution","codepipeline_get_pipeline_state","codepipeline_list_action_executions","codepipeline_list_pipeline_executions","codepipeline_list_pipelines","codepipeline_put_approval_result","codepipeline_retry_stage_execution","codepipeline_start_execution","codepipeline_stop_execution","confluence_add_label","confluence_create_blogpost","confluence_create_comment","confluence_create_page","confluence_create_page_property","confluence_create_space","confluence_create_space_property","confluence_delete_attachment","confluence_delete_blogpost","confluence_delete_comment","confluence_delete_label","confluence_delete_page","confluence_delete_page_property","confluence_delete_space","confluence_delete_space_property","confluence_get_blogpost","confluence_get_page_ancestors","confluence_get_page_children","confluence_get_page_descendants","confluence_get_page_version","confluence_get_pages_by_label","confluence_get_space","confluence_get_task","confluence_get_user","confluence_list_attachments","confluence_list_blogposts","confluence_list_blogposts_in_space","confluence_list_comments","confluence_list_labels","confluence_list_page_properties","confluence_list_page_versions","confluence_list_pages_in_space","confluence_list_space_labels","confluence_list_space_permissions","confluence_list_space_properties","confluence_list_spaces","confluence_list_tasks","confluence_retrieve","confluence_search","confluence_search_in_space","confluence_update","confluence_update_blogpost","confluence_update_comment","confluence_update_space","confluence_update_task","confluence_upload_attachment","context_dev_classify_naics","context_dev_classify_sic","context_dev_crawl","context_dev_extract","context_dev_extract_product","context_dev_extract_products","context_dev_get_brand","context_dev_get_brand_by_email","context_dev_get_brand_by_name","context_dev_get_brand_by_ticker","context_dev_identify_transaction","context_dev_map","context_dev_scrape_fonts","context_dev_scrape_html","context_dev_scrape_images","context_dev_scrape_markdown","context_dev_scrape_styleguide","context_dev_screenshot","context_dev_search","convex_action","convex_document_deltas","convex_list_documents","convex_list_tables","convex_mutation","convex_query","convex_run_function","crowdstrike_create_indicators","crowdstrike_delete_indicators","crowdstrike_delete_rtr_session","crowdstrike_execute_rtr_command","crowdstrike_get_alert_details","crowdstrike_get_case_details","crowdstrike_get_host_group_details","crowdstrike_get_indicator_details","crowdstrike_get_rtr_command_status","crowdstrike_get_sensor_aggregates","crowdstrike_get_sensor_details","crowdstrike_get_vulnerability_details","crowdstrike_init_rtr_session","crowdstrike_perform_host_action","crowdstrike_perform_host_group_action","crowdstrike_query_alerts","crowdstrike_query_cases","crowdstrike_query_host_groups","crowdstrike_query_indicators","crowdstrike_query_sensors","crowdstrike_query_vulnerabilities","crowdstrike_update_alerts","crowdstrike_update_indicators","crunchbase_autocomplete","crunchbase_get_acquisition","crunchbase_get_entity","crunchbase_get_entity_card","crunchbase_get_fields_metadata","crunchbase_get_funding_round","crunchbase_get_organization","crunchbase_get_person","crunchbase_list_deleted_entities","crunchbase_search_acquisitions","crunchbase_search_entities","crunchbase_search_funding_rounds","crunchbase_search_organizations","crunchbase_search_people","cursor_add_followup","cursor_add_followup_v2","cursor_delete_agent","cursor_delete_agent_v2","cursor_download_artifact","cursor_download_artifact_v2","cursor_get_agent","cursor_get_agent_v2","cursor_get_api_key_info","cursor_get_api_key_info_v2","cursor_get_conversation","cursor_get_conversation_v2","cursor_launch_agent","cursor_launch_agent_v2","cursor_list_agents","cursor_list_agents_v2","cursor_list_artifacts","cursor_list_artifacts_v2","cursor_list_models","cursor_list_models_v2","cursor_list_repositories","cursor_list_repositories_v2","cursor_stop_agent","cursor_stop_agent_v2","dagster_delete_run","dagster_get_asset","dagster_get_run","dagster_get_run_logs","dagster_launch_run","dagster_list_assets","dagster_list_jobs","dagster_list_runs","dagster_list_schedules","dagster_list_sensors","dagster_materialize_assets","dagster_reexecute_run","dagster_report_asset_materialization","dagster_start_schedule","dagster_start_sensor","dagster_stop_schedule","dagster_stop_sensor","dagster_terminate_run","dagster_wipe_asset","databricks_cancel_run","databricks_execute_sql","databricks_get_cluster","databricks_get_job","databricks_get_run","databricks_get_run_output","databricks_get_statement","databricks_list_clusters","databricks_list_jobs","databricks_list_runs","databricks_list_warehouses","databricks_run_job","datadog_add_incident_todo","datadog_cancel_downtime","datadog_create_dashboard","datadog_create_downtime","datadog_create_event","datadog_create_incident","datadog_create_monitor","datadog_create_slo","datadog_delete_dashboard","datadog_delete_slo","datadog_get_browser_synthetics_results","datadog_get_dashboard","datadog_get_incident","datadog_get_monitor","datadog_get_security_signal","datadog_get_slo","datadog_get_slo_history","datadog_get_synthetics_results","datadog_get_synthetics_test","datadog_list_dashboards","datadog_list_downtimes","datadog_list_incidents","datadog_list_monitors","datadog_list_security_rules","datadog_list_security_signals","datadog_list_services","datadog_list_slos","datadog_list_synthetics_tests","datadog_mute_monitor","datadog_query_logs","datadog_query_timeseries","datadog_search_spans","datadog_send_logs","datadog_submit_metrics","datadog_trigger_synthetics_tests","datadog_unmute_monitor","datadog_update_incident","datadog_update_security_signal_assignee","datadog_update_security_signal_state","datadog_update_slo","datadog_update_synthetics_status","datagma_enrich_company","datagma_enrich_person","datagma_find_email","datagma_find_phone","datagma_get_credits","daytona_create_sandbox","daytona_delete_sandbox","daytona_download_file","daytona_execute_command","daytona_get_sandbox","daytona_git_clone","daytona_list_files","daytona_list_sandboxes","daytona_run_code","daytona_start_sandbox","daytona_stop_sandbox","daytona_upload_file","deployed_block_executor","deployments_deploy","deployments_get_version","deployments_list_versions","deployments_promote","deployments_undeploy","devin_append_session_tags","devin_archive_session","devin_create_session","devin_get_session","devin_get_session_tags","devin_list_session_attachments","devin_list_session_messages","devin_list_sessions","devin_replace_session_tags","devin_send_message","devin_terminate_session","discord_add_reaction","discord_archive_thread","discord_assign_role","discord_ban_member","discord_bulk_delete_messages","discord_create_channel","discord_create_invite","discord_create_role","discord_create_thread","discord_create_webhook","discord_delete_channel","discord_delete_invite","discord_delete_message","discord_delete_role","discord_delete_webhook","discord_edit_message","discord_execute_webhook","discord_get_channel","discord_get_invite","discord_get_member","discord_get_messages","discord_get_pinned_messages","discord_get_server","discord_get_user","discord_get_webhook","discord_join_thread","discord_kick_member","discord_leave_thread","discord_list_channels","discord_list_roles","discord_pin_message","discord_remove_reaction","discord_remove_role","discord_send_message","discord_unban_member","discord_unpin_message","discord_update_channel","discord_update_member","discord_update_role","docusign_create_from_template","docusign_download_document","docusign_get_envelope","docusign_list_envelopes","docusign_list_recipients","docusign_list_templates","docusign_send_envelope","docusign_void_envelope","downdetector_get_company","downdetector_get_company_attribution","downdetector_get_company_baseline","downdetector_get_company_events","downdetector_get_company_incidents","downdetector_get_company_indicators","downdetector_get_company_last_15","downdetector_get_company_status","downdetector_get_provider","downdetector_get_reports","downdetector_get_site_companies","downdetector_list_categories","downdetector_list_incidents","downdetector_list_sites","downdetector_search_companies","dropbox_copy","dropbox_create_folder","dropbox_create_shared_link","dropbox_delete","dropbox_download","dropbox_get_metadata","dropbox_list_folder","dropbox_list_revisions","dropbox_list_shared_links","dropbox_move","dropbox_restore","dropbox_search","dropbox_upload","dropcontact_enrich_contact","dspy_chain_of_thought","dspy_predict","dspy_react","dub_bulk_create_links","dub_bulk_delete_links","dub_bulk_update_links","dub_create_link","dub_create_tag","dub_delete_link","dub_get_analytics","dub_get_events","dub_get_link","dub_get_links_count","dub_get_qr_code","dub_list_domains","dub_list_folders","dub_list_links","dub_list_tags","dub_update_link","dub_upsert_link","duckduckgo_search","dynamodb_delete","dynamodb_get","dynamodb_introspect","dynamodb_put","dynamodb_query","dynamodb_scan","dynamodb_update","dynatrace_add_problem_comment","dynatrace_add_tags","dynatrace_close_problem","dynatrace_create_settings_object","dynatrace_create_slo","dynatrace_delete_problem_comment","dynatrace_delete_settings_object","dynatrace_delete_slo","dynatrace_delete_tag","dynatrace_execute_synthetic_monitors","dynatrace_get_attack","dynatrace_get_audit_logs","dynatrace_get_entity","dynatrace_get_event","dynatrace_get_metric","dynatrace_get_problem","dynatrace_get_problem_comment","dynatrace_get_security_problem","dynatrace_get_settings_object","dynatrace_get_slo","dynatrace_get_synthetic_batch","dynatrace_ingest_event","dynatrace_ingest_logs","dynatrace_ingest_metrics","dynatrace_list_attacks","dynatrace_list_entities","dynatrace_list_entity_types","dynatrace_list_events","dynatrace_list_metrics","dynatrace_list_problem_comments","dynatrace_list_problems","dynatrace_list_remediation_items","dynatrace_list_security_problems","dynatrace_list_settings_objects","dynatrace_list_settings_schemas","dynatrace_list_slos","dynatrace_list_synthetic_monitors","dynatrace_list_tags","dynatrace_mute_security_problem","dynatrace_mute_security_problems","dynatrace_query_metrics","dynatrace_search_logs","dynatrace_unmute_security_problem","dynatrace_unmute_security_problems","dynatrace_update_problem_comment","dynatrace_update_settings_object","dynatrace_update_slo","elasticsearch_bulk","elasticsearch_cluster_health","elasticsearch_cluster_stats","elasticsearch_count","elasticsearch_create_index","elasticsearch_delete_document","elasticsearch_delete_index","elasticsearch_get_document","elasticsearch_get_index","elasticsearch_index_document","elasticsearch_list_indices","elasticsearch_search","elasticsearch_update_document","elevenlabs_audio_isolation","elevenlabs_edit_voice_settings","elevenlabs_get_user","elevenlabs_get_voice","elevenlabs_get_voice_settings","elevenlabs_list_models","elevenlabs_list_voices","elevenlabs_sound_effects","elevenlabs_speech_to_speech","elevenlabs_tts","emailbison_attach_leads_to_campaign","emailbison_attach_tags_to_leads","emailbison_create_campaign","emailbison_create_lead","emailbison_create_tag","emailbison_get_lead","emailbison_list_campaigns","emailbison_list_leads","emailbison_list_replies","emailbison_list_tags","emailbison_update_campaign","emailbison_update_campaign_status","emailbison_update_lead","embeddings_cohere","embeddings_gemini","embeddings_mistral","embeddings_ollama","embeddings_openai","embeddings_openrouter","enrich_check_credits","enrich_company_funding","enrich_company_lookup","enrich_company_revenue","enrich_disposable_email_check","enrich_email_to_ip","enrich_email_to_person_lite","enrich_email_to_phone","enrich_email_to_profile","enrich_find_email","enrich_get_post_details","enrich_ip_to_company","enrich_linkedin_profile","enrich_linkedin_to_personal_email","enrich_linkedin_to_work_email","enrich_phone_finder","enrich_reverse_hash_lookup","enrich_sales_pointer_people","enrich_search_company","enrich_search_company_activities","enrich_search_company_employees","enrich_search_jobs","enrich_search_logo","enrich_search_people","enrich_search_people_activities","enrich_search_post_comments","enrich_search_post_comments_by_url","enrich_search_post_reactions","enrich_search_post_reactions_by_url","enrich_search_posts","enrich_search_similar_companies","enrich_verify_email","enrichment_run","enrow_find_email","enrow_verify_email","exa_agent","exa_answer","exa_find_similar_links","exa_get_contents","exa_search","extend_parser","extend_parser_v2","fathom_get_summary","fathom_get_transcript","fathom_list_meeting_types","fathom_list_meetings","fathom_list_team_members","fathom_list_teams","file_append","file_compress","file_create_folder","file_decompress","file_delete_folder","file_edit","file_fetch","file_get","file_get_content","file_list","file_manage_sharing","file_move","file_parser","file_parser_v2","file_parser_v3","file_read","file_restore_folder","file_search","file_update_folder","file_write","findymail_find_email_from_linkedin","findymail_find_email_from_name","findymail_find_emails_by_domain","findymail_find_employees","findymail_find_phone","findymail_get_company","findymail_get_credits","findymail_lookup_technologies","findymail_reverse_email_lookup","findymail_search_technologies","findymail_verify_email","firecrawl_agent","firecrawl_batch_scrape","firecrawl_batch_scrape_status","firecrawl_cancel_crawl","firecrawl_crawl","firecrawl_crawl_status","firecrawl_credit_usage","firecrawl_extract","firecrawl_extract_status","firecrawl_map","firecrawl_parse","firecrawl_scrape","firecrawl_search","fireflies_add_to_live_meeting","fireflies_create_bite","fireflies_delete_transcript","fireflies_get_transcript","fireflies_get_user","fireflies_list_bites","fireflies_list_contacts","fireflies_list_transcripts","fireflies_list_users","fireflies_upload_audio","flint_create_task","flint_generate_pages","flint_get_task","function_execute","gamma_check_status","gamma_generate","gamma_generate_from_template","gamma_list_folders","gamma_list_themes","github_add_assignees","github_add_assignees_v2","github_add_labels","github_add_labels_v2","github_cancel_workflow_run","github_cancel_workflow_run_v2","github_check_star","github_check_star_v2","github_close_issue","github_close_issue_v2","github_close_pr","github_close_pr_v2","github_comment","github_comment_v2","github_compare_commits","github_compare_commits_v2","github_create_branch","github_create_branch_v2","github_create_comment_reaction","github_create_comment_reaction_v2","github_create_file","github_create_file_v2","github_create_gist","github_create_gist_v2","github_create_issue","github_create_issue_reaction","github_create_issue_reaction_v2","github_create_issue_v2","github_create_milestone","github_create_milestone_v2","github_create_pr","github_create_pr_review","github_create_pr_review_v2","github_create_pr_v2","github_create_project","github_create_project_v2","github_create_release","github_create_release_v2","github_delete_branch","github_delete_branch_v2","github_delete_comment","github_delete_comment_reaction","github_delete_comment_reaction_v2","github_delete_comment_v2","github_delete_file","github_delete_file_v2","github_delete_gist","github_delete_gist_v2","github_delete_issue_reaction","github_delete_issue_reaction_v2","github_delete_milestone","github_delete_milestone_v2","github_delete_project","github_delete_project_v2","github_delete_release","github_delete_release_v2","github_fork_gist","github_fork_gist_v2","github_fork_repo","github_fork_repo_v2","github_get_branch","github_get_branch_protection","github_get_branch_protection_v2","github_get_branch_v2","github_get_commit","github_get_commit_v2","github_get_file_content","github_get_file_content_v2","github_get_gist","github_get_gist_v2","github_get_issue","github_get_issue_v2","github_get_latest_release","github_get_latest_release_v2","github_get_milestone","github_get_milestone_v2","github_get_pr_files","github_get_pr_files_v2","github_get_project","github_get_project_v2","github_get_readme","github_get_readme_v2","github_get_release","github_get_release_v2","github_get_tree","github_get_tree_v2","github_get_workflow","github_get_workflow_run","github_get_workflow_run_v2","github_get_workflow_v2","github_issue_comment","github_issue_comment_v2","github_job_logs","github_latest_commit","github_latest_commit_v2","github_list_branches","github_list_branches_v2","github_list_commits","github_list_commits_v2","github_list_forks","github_list_forks_v2","github_list_gists","github_list_gists_v2","github_list_issue_comments","github_list_issue_comments_v2","github_list_issues","github_list_issues_v2","github_list_milestones","github_list_milestones_v2","github_list_pr_comments","github_list_pr_comments_v2","github_list_projects","github_list_projects_v2","github_list_prs","github_list_prs_v2","github_list_releases","github_list_releases_v2","github_list_review_threads","github_list_stargazers","github_list_stargazers_v2","github_list_tags","github_list_tags_v2","github_list_workflow_runs","github_list_workflow_runs_v2","github_list_workflows","github_list_workflows_v2","github_merge_pr","github_merge_pr_v2","github_pr","github_pr_v2","github_remove_label","github_remove_label_v2","github_reply_review_thread","github_repo_info","github_repo_info_v2","github_request_reviewers","github_request_reviewers_v2","github_rerun_workflow","github_rerun_workflow_v2","github_resolve_review_thread","github_search_code","github_search_code_v2","github_search_commits","github_search_commits_v2","github_search_issues","github_search_issues_v2","github_search_repos","github_search_repos_v2","github_search_users","github_search_users_v2","github_star_gist","github_star_gist_v2","github_star_repo","github_star_repo_v2","github_status_check_rollup","github_trigger_workflow","github_trigger_workflow_v2","github_unstar_gist","github_unstar_gist_v2","github_unstar_repo","github_unstar_repo_v2","github_update_branch_protection","github_update_branch_protection_v2","github_update_comment","github_update_comment_v2","github_update_file","github_update_file_v2","github_update_gist","github_update_gist_v2","github_update_issue","github_update_issue_v2","github_update_milestone","github_update_milestone_v2","github_update_pr","github_update_pr_v2","github_update_project","github_update_project_v2","github_update_release","github_update_release_v2","gitlab_activate_user","gitlab_add_member","gitlab_add_saml_group_link","gitlab_approve_access_request","gitlab_approve_merge_request","gitlab_approve_user","gitlab_ban_user","gitlab_block_user","gitlab_cancel_pipeline","gitlab_compare_branches","gitlab_create_branch","gitlab_create_file","gitlab_create_issue","gitlab_create_issue_note","gitlab_create_merge_request","gitlab_create_merge_request_note","gitlab_create_pipeline","gitlab_create_release","gitlab_create_user","gitlab_deactivate_user","gitlab_delete_branch","gitlab_delete_issue","gitlab_delete_saml_group_link","gitlab_delete_user","gitlab_delete_user_identity","gitlab_deny_access_request","gitlab_get_file","gitlab_get_group","gitlab_get_issue","gitlab_get_job_log","gitlab_get_merge_request","gitlab_get_merge_request_changes","gitlab_get_pipeline","gitlab_get_project","gitlab_invite_member","gitlab_list_access_requests","gitlab_list_branches","gitlab_list_commits","gitlab_list_groups","gitlab_list_invitations","gitlab_list_issues","gitlab_list_members","gitlab_list_merge_requests","gitlab_list_pipeline_jobs","gitlab_list_pipelines","gitlab_list_projects","gitlab_list_releases","gitlab_list_repository_tree","gitlab_list_saml_group_links","gitlab_list_user_memberships","gitlab_merge_merge_request","gitlab_play_job","gitlab_reject_user","gitlab_remove_member","gitlab_retry_pipeline","gitlab_revoke_invitation","gitlab_search_users","gitlab_unban_user","gitlab_unblock_user","gitlab_update_file","gitlab_update_invitation","gitlab_update_issue","gitlab_update_member","gitlab_update_merge_request","gitlab_update_user","gmail_add_label","gmail_add_label_v2","gmail_archive","gmail_archive_v2","gmail_create_label_v2","gmail_delete","gmail_delete_draft_v2","gmail_delete_label_v2","gmail_delete_v2","gmail_draft","gmail_draft_v2","gmail_edit_draft_v2","gmail_get_draft_v2","gmail_get_thread_v2","gmail_list_drafts_v2","gmail_list_labels_v2","gmail_list_threads_v2","gmail_mark_read","gmail_mark_read_v2","gmail_mark_unread","gmail_mark_unread_v2","gmail_move","gmail_move_v2","gmail_read","gmail_read_v2","gmail_remove_label","gmail_remove_label_v2","gmail_search","gmail_search_v2","gmail_send","gmail_send_v2","gmail_trash_thread_v2","gmail_unarchive","gmail_unarchive_v2","gmail_untrash_thread_v2","gmail_update_label_v2","gong_aggregate_activity","gong_aggregate_by_period","gong_answered_scorecards","gong_ask_anything","gong_assign_flow_prospects","gong_create_call","gong_day_by_day_activity","gong_get_brief","gong_get_call","gong_get_call_transcript","gong_get_coaching","gong_get_extensive_calls","gong_get_folder_content","gong_get_logs","gong_get_prospect_flows","gong_get_user","gong_interaction_stats","gong_list_calls","gong_list_flows","gong_list_library_folders","gong_list_scorecards","gong_list_trackers","gong_list_users","gong_list_workspaces","gong_lookup_email","gong_lookup_phone","gong_purge_email_address","gong_purge_phone_number","gong_unassign_flow_prospects","google_ads_ad_performance","google_ads_campaign_performance","google_ads_list_ad_groups","google_ads_list_campaigns","google_ads_list_customers","google_ads_search","google_appsheet_add_rows","google_appsheet_delete_rows","google_appsheet_edit_rows","google_appsheet_find_rows","google_bigquery_create_dataset","google_bigquery_create_table","google_bigquery_delete_dataset","google_bigquery_delete_table","google_bigquery_get_query_results","google_bigquery_get_table","google_bigquery_insert_rows","google_bigquery_list_datasets","google_bigquery_list_table_data","google_bigquery_list_tables","google_bigquery_query","google_books_volume_details","google_books_volume_search","google_calendar_create","google_calendar_create_calendar","google_calendar_create_calendar_v2","google_calendar_create_v2","google_calendar_delete","google_calendar_delete_calendar","google_calendar_delete_calendar_v2","google_calendar_delete_v2","google_calendar_freebusy","google_calendar_freebusy_v2","google_calendar_get","google_calendar_get_v2","google_calendar_instances","google_calendar_instances_v2","google_calendar_invite","google_calendar_invite_v2","google_calendar_list","google_calendar_list_acl","google_calendar_list_acl_v2","google_calendar_list_calendars","google_calendar_list_calendars_v2","google_calendar_list_v2","google_calendar_move","google_calendar_move_v2","google_calendar_quick_add","google_calendar_quick_add_v2","google_calendar_share_calendar","google_calendar_share_calendar_v2","google_calendar_unshare_calendar","google_calendar_unshare_calendar_v2","google_calendar_update","google_calendar_update_acl","google_calendar_update_acl_v2","google_calendar_update_calendar","google_calendar_update_calendar_v2","google_calendar_update_v2","google_contacts_create","google_contacts_delete","google_contacts_get","google_contacts_list","google_contacts_search","google_contacts_update","google_docs_create","google_docs_create_named_range","google_docs_create_paragraph_bullets","google_docs_delete_content_range","google_docs_delete_named_range","google_docs_delete_paragraph_bullets","google_docs_insert_image","google_docs_insert_page_break","google_docs_insert_table","google_docs_insert_text","google_docs_read","google_docs_replace_text","google_docs_update_paragraph_style","google_docs_update_text_style","google_docs_write","google_drive_copy","google_drive_create_comment","google_drive_create_folder","google_drive_delete","google_drive_delete_comment","google_drive_download","google_drive_export","google_drive_get_about","google_drive_get_content","google_drive_get_file","google_drive_get_revision","google_drive_list","google_drive_list_comments","google_drive_list_permissions","google_drive_list_revisions","google_drive_move","google_drive_search","google_drive_share","google_drive_trash","google_drive_unshare","google_drive_untrash","google_drive_update","google_drive_upload","google_forms_batch_update","google_forms_create_form","google_forms_create_watch","google_forms_delete_watch","google_forms_get_form","google_forms_get_responses","google_forms_list_watches","google_forms_renew_watch","google_forms_set_publish_settings","google_groups_add_alias","google_groups_add_member","google_groups_create_group","google_groups_delete_group","google_groups_get_group","google_groups_get_member","google_groups_get_settings","google_groups_has_member","google_groups_list_aliases","google_groups_list_groups","google_groups_list_members","google_groups_remove_alias","google_groups_remove_member","google_groups_update_group","google_groups_update_member","google_groups_update_settings","google_maps_air_quality","google_maps_directions","google_maps_distance_matrix","google_maps_elevation","google_maps_geocode","google_maps_geolocate","google_maps_place_details","google_maps_places_nearby","google_maps_places_search","google_maps_pollen","google_maps_reverse_geocode","google_maps_snap_to_roads","google_maps_solar","google_maps_speed_limits","google_maps_timezone","google_maps_validate_address","google_meet_create_space","google_meet_end_conference","google_meet_get_conference_record","google_meet_get_space","google_meet_list_conference_records","google_meet_list_participants","google_pagespeed_analyze","google_search","google_sheets_append","google_sheets_append_v2","google_sheets_batch_clear_v2","google_sheets_batch_get_v2","google_sheets_batch_update_v2","google_sheets_clear_v2","google_sheets_copy_sheet_v2","google_sheets_create_spreadsheet_v2","google_sheets_delete_rows_v2","google_sheets_delete_sheet_v2","google_sheets_delete_spreadsheet_v2","google_sheets_get_spreadsheet_v2","google_sheets_read","google_sheets_read_v2","google_sheets_update","google_sheets_update_v2","google_sheets_write","google_sheets_write_v2","google_slides_add_image","google_slides_add_slide","google_slides_batch_update","google_slides_copy_presentation","google_slides_create","google_slides_create_line","google_slides_create_paragraph_bullets","google_slides_create_shape","google_slides_create_sheets_chart","google_slides_create_table","google_slides_create_video","google_slides_delete_object","google_slides_delete_paragraph_bullets","google_slides_delete_table_column","google_slides_delete_table_row","google_slides_delete_text","google_slides_duplicate_object","google_slides_export_presentation","google_slides_get_page","google_slides_get_thumbnail","google_slides_group_objects","google_slides_insert_table_columns","google_slides_insert_table_rows","google_slides_insert_text","google_slides_merge_table_cells","google_slides_read","google_slides_refresh_sheets_chart","google_slides_replace_all_shapes_with_image","google_slides_replace_all_shapes_with_sheets_chart","google_slides_replace_all_text","google_slides_replace_image","google_slides_reroute_line","google_slides_ungroup_objects","google_slides_unmerge_table_cells","google_slides_update_image_properties","google_slides_update_line_category","google_slides_update_line_properties","google_slides_update_page_element_alt_text","google_slides_update_page_element_transform","google_slides_update_page_elements_z_order","google_slides_update_page_properties","google_slides_update_paragraph_style","google_slides_update_shape_properties","google_slides_update_slide_properties","google_slides_update_slides_position","google_slides_update_table_border_properties","google_slides_update_table_cell_properties","google_slides_update_table_column_properties","google_slides_update_table_row_properties","google_slides_update_text_style","google_slides_update_video_properties","google_slides_write","google_tasks_create","google_tasks_delete","google_tasks_get","google_tasks_list","google_tasks_list_task_lists","google_tasks_update","google_translate_detect","google_translate_text","google_vault_add_held_accounts","google_vault_add_matters_permissions","google_vault_close_matters","google_vault_create_matters","google_vault_create_matters_export","google_vault_create_matters_holds","google_vault_create_saved_query","google_vault_delete_matters","google_vault_delete_matters_export","google_vault_delete_matters_holds","google_vault_delete_saved_query","google_vault_download_export_file","google_vault_list_matters","google_vault_list_matters_export","google_vault_list_matters_holds","google_vault_list_saved_queries","google_vault_remove_held_accounts","google_vault_remove_matters_permissions","google_vault_reopen_matters","google_vault_undelete_matters","google_vault_update_matters","google_vault_update_matters_holds","grafana_check_data_source_health","grafana_create_alert_rule","grafana_create_annotation","grafana_create_contact_point","grafana_create_dashboard","grafana_create_folder","grafana_delete_alert_rule","grafana_delete_annotation","grafana_delete_contact_point","grafana_delete_dashboard","grafana_delete_folder","grafana_get_alert_rule","grafana_get_alert_rule_group","grafana_get_dashboard","grafana_get_data_source","grafana_get_folder","grafana_get_health","grafana_list_alert_rules","grafana_list_annotations","grafana_list_contact_points","grafana_list_dashboards","grafana_list_data_sources","grafana_list_folders","grafana_move_folder","grafana_query_data_source","grafana_update_alert_rule","grafana_update_annotation","grafana_update_contact_point","grafana_update_dashboard","grafana_update_folder","grain_create_hook","grain_create_hook_v2","grain_delete_hook","grain_delete_hook_v2","grain_get_recording","grain_get_transcript","grain_list_hooks","grain_list_hooks_v2","grain_list_meeting_types","grain_list_recordings","grain_list_teams","grain_list_views","granola_create_webhook_endpoint","granola_delete_webhook_endpoint","granola_get_note","granola_get_transcript","granola_list_audit_events","granola_list_folders","granola_list_notes","granola_list_webhook_endpoints","granola_update_webhook_endpoint","greenhouse_get_application","greenhouse_get_candidate","greenhouse_get_job","greenhouse_get_user","greenhouse_list_applications","greenhouse_list_candidates","greenhouse_list_departments","greenhouse_list_job_stages","greenhouse_list_jobs","greenhouse_list_offices","greenhouse_list_users","greptile_index_repo","greptile_query","greptile_search","greptile_status","guardrails_validate","harmonic_batch_get_people","harmonic_clear_people_saved_search_net_new_results","harmonic_enrich_person","harmonic_get_company_employees","harmonic_get_email_enrichment_job","harmonic_get_email_enrichment_usage","harmonic_get_enrichment_status","harmonic_get_people_saved_search_net_new_results","harmonic_get_people_saved_search_results","harmonic_get_person","harmonic_list_people_saved_searches","harmonic_search_people_scout","harmonic_submit_email_enrichment_job","hex_cancel_run","hex_create_collection","hex_create_group","hex_deactivate_user","hex_delete_group","hex_get_collection","hex_get_data_connection","hex_get_group","hex_get_project","hex_get_project_runs","hex_get_queried_tables","hex_get_run_status","hex_list_collections","hex_list_data_connections","hex_list_groups","hex_list_projects","hex_list_users","hex_run_project","hex_update_collection","hex_update_group","hex_update_project","http_request","hubspot_add_list_memberships","hubspot_create_appointment","hubspot_create_association","hubspot_create_company","hubspot_create_contact","hubspot_create_deal","hubspot_create_email","hubspot_create_line_item","hubspot_create_list","hubspot_create_note","hubspot_create_ticket","hubspot_delete_association","hubspot_delete_company","hubspot_delete_contact","hubspot_delete_deal","hubspot_delete_line_item","hubspot_delete_ticket","hubspot_get_appointment","hubspot_get_association_labels","hubspot_get_cart","hubspot_get_company","hubspot_get_contact","hubspot_get_deal","hubspot_get_email","hubspot_get_line_item","hubspot_get_list","hubspot_get_list_memberships","hubspot_get_marketing_event","hubspot_get_note","hubspot_get_properties","hubspot_get_quote","hubspot_get_ticket","hubspot_get_users","hubspot_list_appointments","hubspot_list_associations","hubspot_list_carts","hubspot_list_companies","hubspot_list_contacts","hubspot_list_deals","hubspot_list_emails","hubspot_list_line_items","hubspot_list_lists","hubspot_list_marketing_events","hubspot_list_notes","hubspot_list_owners","hubspot_list_quotes","hubspot_list_tickets","hubspot_remove_list_memberships","hubspot_search_companies","hubspot_search_contacts","hubspot_search_deals","hubspot_search_emails","hubspot_search_line_items","hubspot_search_notes","hubspot_search_quotes","hubspot_search_tickets","hubspot_update_appointment","hubspot_update_company","hubspot_update_contact","hubspot_update_deal","hubspot_update_line_item","hubspot_update_ticket","huggingface_chat","hunter_companies_find","hunter_discover","hunter_domain_search","hunter_email_count","hunter_email_finder","hunter_email_verifier","iam_add_user_to_group","iam_attach_role_policy","iam_attach_user_policy","iam_create_access_key","iam_create_role","iam_create_user","iam_delete_access_key","iam_delete_role","iam_delete_user","iam_detach_role_policy","iam_detach_user_policy","iam_get_policy","iam_get_role","iam_get_user","iam_list_access_keys","iam_list_attached_role_policies","iam_list_attached_user_policies","iam_list_groups","iam_list_policies","iam_list_roles","iam_list_users","iam_remove_user_from_group","iam_simulate_principal_policy","iam_update_access_key","icypeas_find_email","icypeas_verify_email","identity_center_check_assignment_deletion_status","identity_center_check_assignment_status","identity_center_create_account_assignment","identity_center_delete_account_assignment","identity_center_describe_account","identity_center_describe_group","identity_center_describe_user","identity_center_get_group","identity_center_get_user","identity_center_list_account_assignments","identity_center_list_accounts","identity_center_list_assignments_for_account","identity_center_list_group_memberships","identity_center_list_groups","identity_center_list_instances","identity_center_list_permission_sets","image_generate","incidentio_actions_create","incidentio_actions_list","incidentio_actions_show","incidentio_actions_update","incidentio_alert_events_create","incidentio_alerts_list","incidentio_alerts_resolve","incidentio_alerts_show","incidentio_catalog_entries_list","incidentio_catalog_types_list","incidentio_custom_fields_create","incidentio_custom_fields_delete","incidentio_custom_fields_list","incidentio_custom_fields_show","incidentio_custom_fields_update","incidentio_escalation_paths_create","incidentio_escalation_paths_delete","incidentio_escalation_paths_list","incidentio_escalation_paths_show","incidentio_escalation_paths_update","incidentio_escalations_cancel","incidentio_escalations_create","incidentio_escalations_list","incidentio_escalations_show","incidentio_follow_ups_create","incidentio_follow_ups_list","incidentio_follow_ups_show","incidentio_follow_ups_update","incidentio_incident_alerts_list","incidentio_incident_memberships_create","incidentio_incident_memberships_revoke","incidentio_incident_participants_list","incidentio_incident_roles_create","incidentio_incident_roles_delete","incidentio_incident_roles_list","incidentio_incident_roles_show","incidentio_incident_roles_update","incidentio_incident_statuses_list","incidentio_incident_timestamps_list","incidentio_incident_timestamps_show","incidentio_incident_types_list","incidentio_incident_updates_list","incidentio_incidents_create","incidentio_incidents_list","incidentio_incidents_show","incidentio_incidents_update","incidentio_on_call_now","incidentio_schedule_entries_list","incidentio_schedule_overrides_create","incidentio_schedule_overrides_list","incidentio_schedules_create","incidentio_schedules_delete","incidentio_schedules_list","incidentio_schedules_show","incidentio_schedules_update","incidentio_severities_list","incidentio_teams_list","incidentio_teams_show","incidentio_users_list","incidentio_users_show","incidentio_workflows_create","incidentio_workflows_delete","incidentio_workflows_list","incidentio_workflows_show","incidentio_workflows_update","infisical_create_secret","infisical_delete_secret","infisical_get_secret","infisical_list_secrets","infisical_update_secret","instagram_delete_comment","instagram_download_media","instagram_get_account_insights","instagram_get_container_status","instagram_get_conversation_messages","instagram_get_media","instagram_get_media_insights","instagram_get_message","instagram_get_profile","instagram_get_publishing_limit","instagram_hide_comment","instagram_list_comments","instagram_list_conversations","instagram_list_media","instagram_list_stories","instagram_private_reply","instagram_publish_carousel","instagram_publish_image","instagram_publish_reel","instagram_publish_story","instagram_publish_video","instagram_reply_to_comment","instagram_send_text_message","instagram_set_comments_enabled","instantly_activate_campaign","instantly_create_campaign","instantly_create_lead","instantly_create_lead_list","instantly_delete_campaign","instantly_delete_leads","instantly_get_lead","instantly_list_campaigns","instantly_list_emails","instantly_list_lead_lists","instantly_list_leads","instantly_patch_campaign","instantly_patch_lead","instantly_pause_campaign","instantly_reply_to_email","instantly_update_lead_interest_status","intercom_assign_conversation_v2","intercom_attach_contact_to_company_v2","intercom_close_conversation_v2","intercom_create_company","intercom_create_company_v2","intercom_create_contact","intercom_create_contact_v2","intercom_create_event_v2","intercom_create_message","intercom_create_message_v2","intercom_create_note_v2","intercom_create_tag_v2","intercom_create_ticket","intercom_create_ticket_v2","intercom_delete_contact","intercom_delete_contact_v2","intercom_detach_contact_from_company_v2","intercom_get_company","intercom_get_company_v2","intercom_get_contact","intercom_get_contact_v2","intercom_get_conversation","intercom_get_conversation_v2","intercom_get_ticket","intercom_get_ticket_v2","intercom_list_admins_v2","intercom_list_companies","intercom_list_companies_v2","intercom_list_contacts","intercom_list_contacts_v2","intercom_list_conversations","intercom_list_conversations_v2","intercom_list_tags_v2","intercom_open_conversation_v2","intercom_reply_conversation","intercom_reply_conversation_v2","intercom_search_contacts","intercom_search_contacts_v2","intercom_search_conversations","intercom_search_conversations_v2","intercom_snooze_conversation_v2","intercom_tag_contact_v2","intercom_tag_conversation_v2","intercom_untag_contact_v2","intercom_update_contact","intercom_update_contact_v2","intercom_update_ticket_v2","jina_read_url","jina_search","jira_add_attachment","jira_add_comment","jira_add_watcher","jira_add_worklog","jira_assign_issue","jira_bulk_read","jira_create_issue_link","jira_delete_attachment","jira_delete_comment","jira_delete_issue","jira_delete_issue_link","jira_delete_worklog","jira_get_attachments","jira_get_comments","jira_get_fields","jira_get_project","jira_get_transitions","jira_get_users","jira_get_worklogs","jira_list_issue_types","jira_list_projects","jira_remove_watcher","jira_retrieve","jira_search_issues","jira_search_users","jira_transition_issue","jira_update","jira_update_comment","jira_update_worklog","jira_write","jotform_add_label_resources","jotform_clone_form","jotform_create_form","jotform_create_label","jotform_create_question","jotform_create_questions","jotform_create_report","jotform_create_submission","jotform_create_submissions","jotform_create_webhook","jotform_delete_form","jotform_delete_label","jotform_delete_question","jotform_delete_report","jotform_delete_submission","jotform_delete_webhook","jotform_get_form","jotform_get_form_properties","jotform_get_history","jotform_get_label","jotform_get_question","jotform_get_report","jotform_get_settings","jotform_get_submission","jotform_get_usage","jotform_get_user","jotform_list_form_files","jotform_list_form_reports","jotform_list_form_submissions","jotform_list_forms","jotform_list_label_resources","jotform_list_labels","jotform_list_questions","jotform_list_reports","jotform_list_submissions","jotform_list_subusers","jotform_list_webhooks","jotform_remove_label_resources","jotform_update_form_properties","jotform_update_label","jotform_update_question","jotform_update_settings","jotform_update_submission","jsm_add_comment","jsm_add_customer","jsm_add_organization","jsm_add_participants","jsm_answer_approval","jsm_attach_form","jsm_copy_forms","jsm_create_object","jsm_create_organization","jsm_create_request","jsm_delete_form","jsm_delete_object","jsm_externalise_form","jsm_get_approvals","jsm_get_comments","jsm_get_customers","jsm_get_form","jsm_get_form_answers","jsm_get_form_structure","jsm_get_form_templates","jsm_get_issue_forms","jsm_get_object","jsm_get_object_schema","jsm_get_object_type_attributes","jsm_get_organizations","jsm_get_participants","jsm_get_queues","jsm_get_request","jsm_get_request_type_fields","jsm_get_request_types","jsm_get_requests","jsm_get_service_desks","jsm_get_sla","jsm_get_transitions","jsm_internalise_form","jsm_list_object_schemas","jsm_list_object_types","jsm_reopen_form","jsm_save_form_answers","jsm_search_objects_aql","jsm_submit_form","jsm_transition_request","jsm_update_object","jupyter_copy_content","jupyter_create_file","jupyter_create_session","jupyter_delete_content","jupyter_delete_session","jupyter_get_content","jupyter_interrupt_kernel","jupyter_list_contents","jupyter_list_kernels","jupyter_list_kernelspecs","jupyter_list_sessions","jupyter_rename_content","jupyter_restart_kernel","jupyter_start_kernel","jupyter_stop_kernel","jupyter_upload_file","kalshi_amend_order","kalshi_amend_order_v2","kalshi_cancel_order","kalshi_cancel_order_v2","kalshi_create_order","kalshi_create_order_v2","kalshi_get_balance","kalshi_get_balance_v2","kalshi_get_candlesticks","kalshi_get_candlesticks_v2","kalshi_get_event","kalshi_get_event_candlesticks","kalshi_get_event_candlesticks_v2","kalshi_get_event_v2","kalshi_get_events","kalshi_get_events_v2","kalshi_get_exchange_announcements","kalshi_get_exchange_announcements_v2","kalshi_get_exchange_schedule","kalshi_get_exchange_schedule_v2","kalshi_get_exchange_status","kalshi_get_exchange_status_v2","kalshi_get_fills","kalshi_get_fills_v2","kalshi_get_market","kalshi_get_market_v2","kalshi_get_markets","kalshi_get_markets_v2","kalshi_get_order","kalshi_get_order_v2","kalshi_get_orderbook","kalshi_get_orderbook_v2","kalshi_get_orders","kalshi_get_orders_v2","kalshi_get_positions","kalshi_get_positions_v2","kalshi_get_series_by_ticker","kalshi_get_series_by_ticker_v2","kalshi_get_series_list","kalshi_get_series_list_v2","kalshi_get_settlements","kalshi_get_settlements_v2","kalshi_get_trades","kalshi_get_trades_v2","ketch_get_consent","ketch_get_subscriptions","ketch_invoke_right","ketch_set_consent","ketch_set_subscriptions","knowledge_create_document","knowledge_delete_chunk","knowledge_delete_document","knowledge_get_connector","knowledge_get_document","knowledge_list_chunks","knowledge_list_connectors","knowledge_list_documents","knowledge_list_tags","knowledge_search","knowledge_trigger_sync","knowledge_update_chunk","knowledge_upload_chunk","knowledge_upsert_document","lambda_add_permission","lambda_create_alias","lambda_create_event_source_mapping","lambda_create_function","lambda_create_function_url_config","lambda_delete_alias","lambda_delete_event_source_mapping","lambda_delete_function","lambda_delete_function_concurrency","lambda_delete_function_event_invoke_config","lambda_delete_function_url_config","lambda_delete_provisioned_concurrency_config","lambda_get_account_settings","lambda_get_alias","lambda_get_event_source_mapping","lambda_get_function","lambda_get_function_concurrency","lambda_get_function_configuration","lambda_get_function_event_invoke_config","lambda_get_function_recursion_config","lambda_get_function_url_config","lambda_get_layer_version","lambda_get_policy","lambda_get_provisioned_concurrency_config","lambda_get_runtime_management_config","lambda_invoke","lambda_list_aliases","lambda_list_event_source_mappings","lambda_list_function_event_invoke_configs","lambda_list_function_url_configs","lambda_list_functions","lambda_list_layer_versions","lambda_list_layers","lambda_list_provisioned_concurrency_configs","lambda_list_tags","lambda_list_versions_by_function","lambda_publish_version","lambda_put_function_concurrency","lambda_put_function_event_invoke_config","lambda_put_function_recursion_config","lambda_put_provisioned_concurrency_config","lambda_put_runtime_management_config","lambda_remove_permission","lambda_tag_resource","lambda_untag_resource","lambda_update_alias","lambda_update_event_source_mapping","lambda_update_function_code","lambda_update_function_configuration","lambda_update_function_url_config","langsmith_create_feedback","langsmith_create_run","langsmith_create_runs_batch","langsmith_get_run","langsmith_update_run","latex_compile","latex_get_package","latex_list_fonts","latex_search_packages","launchdarkly_create_flag","launchdarkly_delete_flag","launchdarkly_get_audit_log","launchdarkly_get_flag","launchdarkly_get_flag_status","launchdarkly_list_environments","launchdarkly_list_flags","launchdarkly_list_members","launchdarkly_list_projects","launchdarkly_list_segments","launchdarkly_toggle_flag","launchdarkly_update_flag","leadmagic_company_search","leadmagic_email_to_profile","leadmagic_find_email","leadmagic_find_mobile","leadmagic_get_credits","leadmagic_profile_search","leadmagic_profile_to_email","leadmagic_role_finder","leadmagic_validate_email","lemlist_get_activities","lemlist_get_lead","lemlist_send_email","linear_add_label_to_issue","linear_add_label_to_project","linear_archive_issue","linear_archive_label","linear_archive_project","linear_create_attachment","linear_create_comment","linear_create_customer","linear_create_customer_request","linear_create_customer_status","linear_create_customer_tier","linear_create_cycle","linear_create_favorite","linear_create_issue","linear_create_issue_relation","linear_create_label","linear_create_project","linear_create_project_label","linear_create_project_milestone","linear_create_project_status","linear_create_project_update","linear_create_workflow_state","linear_delete_attachment","linear_delete_comment","linear_delete_customer","linear_delete_customer_status","linear_delete_customer_tier","linear_delete_issue","linear_delete_issue_relation","linear_delete_project","linear_delete_project_label","linear_delete_project_milestone","linear_delete_project_status","linear_get_active_cycle","linear_get_customer","linear_get_cycle","linear_get_issue","linear_get_project","linear_get_viewer","linear_list_attachments","linear_list_comments","linear_list_customer_requests","linear_list_customer_statuses","linear_list_customer_tiers","linear_list_customers","linear_list_cycles","linear_list_favorites","linear_list_issue_relations","linear_list_labels","linear_list_notifications","linear_list_project_labels","linear_list_project_milestones","linear_list_project_statuses","linear_list_project_updates","linear_list_projects","linear_list_teams","linear_list_users","linear_list_workflow_states","linear_merge_customers","linear_read_issues","linear_remove_label_from_issue","linear_remove_label_from_project","linear_search_issues","linear_unarchive_issue","linear_update_attachment","linear_update_comment","linear_update_customer","linear_update_customer_request","linear_update_customer_status","linear_update_customer_tier","linear_update_issue","linear_update_label","linear_update_notification","linear_update_project","linear_update_project_label","linear_update_project_milestone","linear_update_project_status","linear_update_workflow_state","linkedin_get_profile","linkedin_share_post","linkup_search","linq_add_participant","linq_check_imessage","linq_check_rcs","linq_create_attachment","linq_create_chat","linq_create_contact_card","linq_create_webhook_subscription","linq_delete_attachment","linq_delete_message","linq_delete_webhook_subscription","linq_edit_message","linq_get_attachment","linq_get_chat","linq_get_contact_card","linq_get_message","linq_get_webhook_subscription","linq_leave_chat","linq_list_chats","linq_list_messages","linq_list_phone_numbers","linq_list_thread","linq_list_webhook_events","linq_list_webhook_subscriptions","linq_mark_chat_read","linq_react_to_message","linq_remove_participant","linq_send_message","linq_send_voice_memo","linq_share_contact_card","linq_start_typing","linq_stop_typing","linq_update_chat","linq_update_contact_card","linq_update_webhook_subscription","llm_chat","logfire_get_token_info","logfire_get_trace","logfire_query","logfire_search_records","logrocket_create_release","logrocket_get_audit_logs","logrocket_get_highlights","logrocket_identify_user","logrocket_list_exported_sessions","logrocket_request_highlights","logs_get","logs_get_execution","logs_get_run_details","logs_query","logs_query_runs","loops_check_contact_suppression","loops_create_contact","loops_create_contact_property","loops_delete_contact","loops_find_contact","loops_get_transactional_email","loops_list_contact_properties","loops_list_mailing_lists","loops_list_transactional_emails","loops_remove_contact_suppression","loops_send_event","loops_send_transactional_email","loops_update_contact","luma_add_guests","luma_cancel_event","luma_create_event","luma_get_event","luma_get_guest","luma_get_guests","luma_list_events","luma_lookup_event","luma_send_invites","luma_update_event","luma_update_guest_status","mailchimp_add_member","mailchimp_add_member_tags","mailchimp_add_or_update_member","mailchimp_add_segment_member","mailchimp_add_subscriber_to_automation","mailchimp_archive_member","mailchimp_create_audience","mailchimp_create_batch_operation","mailchimp_create_campaign","mailchimp_create_interest","mailchimp_create_interest_category","mailchimp_create_landing_page","mailchimp_create_merge_field","mailchimp_create_segment","mailchimp_create_template","mailchimp_delete_audience","mailchimp_delete_batch_operation","mailchimp_delete_campaign","mailchimp_delete_interest","mailchimp_delete_interest_category","mailchimp_delete_landing_page","mailchimp_delete_member","mailchimp_delete_merge_field","mailchimp_delete_segment","mailchimp_delete_template","mailchimp_get_audience","mailchimp_get_audiences","mailchimp_get_automation","mailchimp_get_automations","mailchimp_get_batch_operation","mailchimp_get_batch_operations","mailchimp_get_campaign","mailchimp_get_campaign_content","mailchimp_get_campaign_report","mailchimp_get_campaign_reports","mailchimp_get_campaigns","mailchimp_get_interest","mailchimp_get_interest_categories","mailchimp_get_interest_category","mailchimp_get_interests","mailchimp_get_landing_page","mailchimp_get_landing_pages","mailchimp_get_member","mailchimp_get_member_tags","mailchimp_get_members","mailchimp_get_merge_field","mailchimp_get_merge_fields","mailchimp_get_segment","mailchimp_get_segment_members","mailchimp_get_segments","mailchimp_get_template","mailchimp_get_templates","mailchimp_pause_automation","mailchimp_publish_landing_page","mailchimp_remove_member_tags","mailchimp_remove_segment_member","mailchimp_replicate_campaign","mailchimp_schedule_campaign","mailchimp_send_campaign","mailchimp_set_campaign_content","mailchimp_start_automation","mailchimp_unarchive_member","mailchimp_unpublish_landing_page","mailchimp_unschedule_campaign","mailchimp_update_audience","mailchimp_update_campaign","mailchimp_update_interest","mailchimp_update_interest_category","mailchimp_update_landing_page","mailchimp_update_member","mailchimp_update_merge_field","mailchimp_update_segment","mailchimp_update_template","mailgun_add_list_member","mailgun_create_mailing_list","mailgun_get_domain","mailgun_get_mailing_list","mailgun_get_message","mailgun_list_domains","mailgun_list_messages","mailgun_send_message","managed_agent_archive_session","managed_agent_create_session","managed_agent_delete_session","managed_agent_get_session","managed_agent_interrupt_session","managed_agent_list_events","managed_agent_respond_custom_tool","managed_agent_respond_tool_confirmation","managed_agent_run_session","managed_agent_send_message","managed_agent_update_session","manageengine_sdp_add_change_note","manageengine_sdp_add_problem_note","manageengine_sdp_add_request_note","manageengine_sdp_create_asset","manageengine_sdp_create_change","manageengine_sdp_create_problem","manageengine_sdp_create_request","manageengine_sdp_create_solution","manageengine_sdp_delete_asset","manageengine_sdp_delete_change","manageengine_sdp_delete_problem","manageengine_sdp_delete_request","manageengine_sdp_delete_solution","manageengine_sdp_get_asset","manageengine_sdp_get_change","manageengine_sdp_get_problem","manageengine_sdp_get_request","manageengine_sdp_get_solution","manageengine_sdp_list_assets","manageengine_sdp_list_change_notes","manageengine_sdp_list_changes","manageengine_sdp_list_problem_notes","manageengine_sdp_list_problems","manageengine_sdp_list_request_notes","manageengine_sdp_list_requests","manageengine_sdp_list_solutions","manageengine_sdp_update_asset","manageengine_sdp_update_change","manageengine_sdp_update_problem","manageengine_sdp_update_request","manageengine_sdp_update_solution","mem0_add_memories","mem0_get_memories","mem0_search_memories","memory_add","memory_delete","memory_get","memory_get_all","microsoft_ad_add_directory_role_member","microsoft_ad_add_group_member","microsoft_ad_add_user_app_role_assignment","microsoft_ad_assign_license","microsoft_ad_create_group","microsoft_ad_create_user","microsoft_ad_delete_group","microsoft_ad_delete_user","microsoft_ad_get_conditional_access_policy","microsoft_ad_get_device","microsoft_ad_get_group","microsoft_ad_get_user","microsoft_ad_list_authentication_methods","microsoft_ad_list_conditional_access_policies","microsoft_ad_list_devices","microsoft_ad_list_directory_audits","microsoft_ad_list_directory_role_members","microsoft_ad_list_directory_roles","microsoft_ad_list_group_members","microsoft_ad_list_groups","microsoft_ad_list_service_principal_app_role_assignments","microsoft_ad_list_service_principals","microsoft_ad_list_sign_ins","microsoft_ad_list_subscribed_skus","microsoft_ad_list_user_app_role_assignments","microsoft_ad_list_user_devices","microsoft_ad_list_user_licenses","microsoft_ad_list_users","microsoft_ad_remove_directory_role_member","microsoft_ad_remove_group_member","microsoft_ad_remove_user_app_role_assignment","microsoft_ad_reset_password","microsoft_ad_revoke_sign_in_sessions","microsoft_ad_set_password","microsoft_ad_update_group","microsoft_ad_update_user","microsoft_dataverse_associate","microsoft_dataverse_create_multiple","microsoft_dataverse_create_record","microsoft_dataverse_delete_record","microsoft_dataverse_disassociate","microsoft_dataverse_download_file","microsoft_dataverse_execute_action","microsoft_dataverse_execute_function","microsoft_dataverse_fetchxml_query","microsoft_dataverse_get_entity_metadata","microsoft_dataverse_get_record","microsoft_dataverse_list_records","microsoft_dataverse_search","microsoft_dataverse_update_multiple","microsoft_dataverse_update_record","microsoft_dataverse_upload_file","microsoft_dataverse_upsert_record","microsoft_dataverse_whoami","microsoft_dynamics_365_close_case","microsoft_dynamics_365_close_opportunity","microsoft_dynamics_365_create_record","microsoft_dynamics_365_get_record","microsoft_dynamics_365_list_records","microsoft_dynamics_365_qualify_lead","microsoft_dynamics_365_search_records","microsoft_dynamics_365_update_record","microsoft_excel_clear_range","microsoft_excel_create_table","microsoft_excel_delete_worksheet","microsoft_excel_format_range","microsoft_excel_read","microsoft_excel_read_v2","microsoft_excel_sort_range","microsoft_excel_table_add","microsoft_excel_worksheet_add","microsoft_excel_write","microsoft_excel_write_v2","microsoft_planner_create_bucket","microsoft_planner_create_plan","microsoft_planner_create_task","microsoft_planner_delete_bucket","microsoft_planner_delete_plan","microsoft_planner_delete_task","microsoft_planner_get_plan_details","microsoft_planner_get_task_details","microsoft_planner_list_buckets","microsoft_planner_list_plans","microsoft_planner_read_bucket","microsoft_planner_read_plan","microsoft_planner_read_task","microsoft_planner_update_bucket","microsoft_planner_update_plan","microsoft_planner_update_plan_details","microsoft_planner_update_task","microsoft_planner_update_task_details","microsoft_teams_delete_channel_message","microsoft_teams_delete_chat_message","microsoft_teams_get_message","microsoft_teams_list_channel_members","microsoft_teams_list_channels","microsoft_teams_list_chat_members","microsoft_teams_list_chats","microsoft_teams_list_team_members","microsoft_teams_list_teams","microsoft_teams_read_channel","microsoft_teams_read_chat","microsoft_teams_reply_to_message","microsoft_teams_set_reaction","microsoft_teams_unset_reaction","microsoft_teams_update_channel_message","microsoft_teams_update_chat_message","microsoft_teams_write_channel","microsoft_teams_write_chat","microsoft_word_append","microsoft_word_create","microsoft_word_create_from_template","microsoft_word_export_pdf","microsoft_word_list","microsoft_word_read","microsoft_word_replace_text","microsoft_word_update","millionverifier_get_credits","millionverifier_verify_email","mintlify_create_agent_job","mintlify_create_assistant_message","mintlify_detect_ai_prose","mintlify_get_agent_job","mintlify_get_assistant_caller_stats","mintlify_get_assistant_conversations","mintlify_get_feedback","mintlify_get_feedback_by_page","mintlify_get_page_content","mintlify_get_searches","mintlify_get_update_status","mintlify_get_views","mintlify_get_visitors","mintlify_search","mintlify_send_agent_message","mintlify_trigger_automation","mintlify_trigger_preview","mintlify_trigger_update","mistral_parser","mistral_parser_v2","mistral_parser_v3","modal_call_function","modal_chat_completion","modal_list_models","monday_archive_item","monday_change_column_value","monday_create_board","monday_create_column","monday_create_group","monday_create_item","monday_create_subitem","monday_create_update","monday_delete_item","monday_duplicate_item","monday_get_board","monday_get_groups","monday_get_item","monday_get_items","monday_list_boards","monday_move_item_to_group","monday_search_items","monday_update_item","mongodb_delete","mongodb_execute","mongodb_insert","mongodb_introspect","mongodb_query","mongodb_update","mssql_delete","mssql_execute","mssql_insert","mssql_introspect","mssql_query","mssql_update","mysql_delete","mysql_execute","mysql_insert","mysql_introspect","mysql_query","mysql_update","neo4j_create","neo4j_delete","neo4j_execute","neo4j_introspect","neo4j_merge","neo4j_query","neo4j_update","netsuite_attach_record","netsuite_batch_create_records","netsuite_batch_delete_records","netsuite_batch_get_records","netsuite_batch_update_records","netsuite_batch_upsert_records","netsuite_create_record","netsuite_delete_record","netsuite_detach_record","netsuite_execute_action","netsuite_execute_dataset","netsuite_execute_suiteql","netsuite_get_async_result","netsuite_get_async_status","netsuite_get_governance_limits","netsuite_get_record","netsuite_get_record_form","netsuite_get_record_metadata","netsuite_get_select_options","netsuite_get_server_time","netsuite_get_subresource","netsuite_list_datasets","netsuite_list_record_types","netsuite_list_records","netsuite_transform_record","netsuite_update_record","netsuite_upsert_record","neverbounce_get_credits","neverbounce_verify_email","new_relic_create_deployment_event","new_relic_get_entity","new_relic_nrql_query","new_relic_search_entities","notion_add_database_row","notion_add_database_row_v2","notion_append_blocks","notion_append_blocks_v2","notion_create_comment","notion_create_comment_v2","notion_create_database","notion_create_database_v2","notion_create_page","notion_create_page_v2","notion_delete_block","notion_delete_block_v2","notion_list_comments","notion_list_comments_v2","notion_list_users","notion_list_users_v2","notion_query_database","notion_query_database_v2","notion_read","notion_read_database","notion_read_database_v2","notion_read_v2","notion_retrieve_block","notion_retrieve_block_children","notion_retrieve_block_children_v2","notion_retrieve_block_v2","notion_retrieve_user","notion_retrieve_user_v2","notion_search","notion_search_v2","notion_update_block","notion_update_block_v2","notion_update_page","notion_update_page_v2","notion_write","notion_write_v2","obsidian_append_active","obsidian_append_note","obsidian_append_periodic_note","obsidian_create_note","obsidian_delete_note","obsidian_execute_command","obsidian_get_active","obsidian_get_note","obsidian_get_periodic_note","obsidian_list_commands","obsidian_list_files","obsidian_open_file","obsidian_patch_active","obsidian_patch_note","obsidian_search","okta_activate_group_rule","okta_activate_user","okta_add_user_to_group","okta_assign_group_to_app","okta_assign_user_role","okta_assign_user_to_app","okta_clear_user_sessions","okta_create_group","okta_create_group_rule","okta_create_user","okta_deactivate_group_rule","okta_deactivate_user","okta_delete_group","okta_delete_group_rule","okta_delete_user","okta_enroll_factor","okta_get_app","okta_get_factor","okta_get_group","okta_get_group_rule","okta_get_logs","okta_get_session","okta_get_user","okta_list_app_groups","okta_list_app_users","okta_list_apps","okta_list_factors","okta_list_group_members","okta_list_group_rules","okta_list_groups","okta_list_user_roles","okta_list_users","okta_remove_group_from_app","okta_remove_user_from_app","okta_remove_user_from_group","okta_remove_user_role","okta_reset_all_factors","okta_reset_factor","okta_reset_password","okta_revoke_session","okta_suspend_user","okta_unsuspend_user","okta_update_group","okta_update_user","onedrive_copy","onedrive_create_folder","onedrive_create_share_link","onedrive_delete","onedrive_download","onedrive_get_drive_info","onedrive_get_item","onedrive_list","onedrive_move","onedrive_search","onedrive_upload","onepassword_create_item","onepassword_delete_item","onepassword_get_item","onepassword_get_item_file","onepassword_get_vault","onepassword_list_items","onepassword_list_vaults","onepassword_replace_item","onepassword_resolve_secret","onepassword_update_item","openai_embeddings","openai_image","outlook_calendar_create_event","outlook_calendar_delete_event","outlook_calendar_get_event","outlook_calendar_list_events","outlook_calendar_respond","outlook_calendar_update_event","outlook_copy","outlook_create_folder","outlook_delete","outlook_draft","outlook_forward","outlook_get_attachment","outlook_list_attachments","outlook_list_folders","outlook_mark_read","outlook_mark_unread","outlook_move","outlook_read","outlook_reply","outlook_reply_all","outlook_search","outlook_send","outlook_update_message","pagerduty_add_note","pagerduty_create_incident","pagerduty_get_incident","pagerduty_get_service","pagerduty_list_escalation_policies","pagerduty_list_incident_alerts","pagerduty_list_incidents","pagerduty_list_oncalls","pagerduty_list_schedules","pagerduty_list_services","pagerduty_list_users","pagerduty_merge_incidents","pagerduty_send_event","pagerduty_snooze_incident","pagerduty_update_incident","parallel_deep_research","parallel_extract","parallel_search","pdl_autocomplete","pdl_bulk_company_enrich","pdl_bulk_person_enrich","pdl_clean_company","pdl_clean_location","pdl_clean_school","pdl_company_enrich","pdl_company_search","pdl_person_enrich","pdl_person_identify","pdl_person_search","perplexity_chat","perplexity_search","persona_approve_inquiry","persona_create_account","persona_create_inquiry","persona_create_report","persona_decline_inquiry","persona_expire_inquiry","persona_generate_inquiry_link","persona_get_account","persona_get_case","persona_get_document","persona_get_inquiry","persona_get_report","persona_get_verification","persona_import_accounts","persona_list_accounts","persona_list_cases","persona_list_inquiries","persona_list_inquiry_templates","persona_list_reports","persona_mark_inquiry_for_review","persona_print_inquiry_pdf","persona_redact_account","persona_redact_inquiry","persona_resume_inquiry","persona_update_account","persona_update_inquiry","pinecone_delete_vectors","pinecone_describe_index","pinecone_describe_index_stats","pinecone_fetch","pinecone_generate_embeddings","pinecone_list_indexes","pinecone_list_vector_ids","pinecone_search_text","pinecone_search_vector","pinecone_update_vector","pinecone_upsert_text","pipedrive_create_activity","pipedrive_create_deal","pipedrive_create_lead","pipedrive_create_project","pipedrive_delete_lead","pipedrive_get_activities","pipedrive_get_all_deals","pipedrive_get_deal","pipedrive_get_files","pipedrive_get_leads","pipedrive_get_mail_messages","pipedrive_get_mail_thread","pipedrive_get_pipeline_deals","pipedrive_get_pipelines","pipedrive_get_projects","pipedrive_update_activity","pipedrive_update_deal","pipedrive_update_lead","pitchbook_company_active_investors","pitchbook_company_bio","pitchbook_company_deal_service_providers","pitchbook_company_deals","pitchbook_company_financials","pitchbook_company_general_service_providers","pitchbook_company_industries","pitchbook_company_investors","pitchbook_company_most_recent_debt_financing","pitchbook_company_most_recent_financials","pitchbook_company_most_recent_financing","pitchbook_company_search","pitchbook_company_similar_companies","pitchbook_company_social_analytics","pitchbook_company_updates","pitchbook_company_vc_exit_predictions","pitchbook_contracts_history","pitchbook_cost_of_calls","pitchbook_credit_history","pitchbook_credit_news","pitchbook_credit_news_bulk","pitchbook_credit_news_most_recent","pitchbook_credit_news_search","pitchbook_deal_bio","pitchbook_deal_cap_table_history","pitchbook_deal_debt_lenders","pitchbook_deal_detailed","pitchbook_deal_investors","pitchbook_deal_multiples","pitchbook_deal_search","pitchbook_deal_service_providers","pitchbook_deal_stock_info","pitchbook_deal_tranche_info","pitchbook_deal_updates","pitchbook_deal_valuation","pitchbook_entity_affiliates","pitchbook_entity_locations","pitchbook_entity_news","pitchbook_entity_people","pitchbook_entity_updates","pitchbook_fund_active_investments","pitchbook_fund_benchmark","pitchbook_fund_bio","pitchbook_fund_cash_flows","pitchbook_fund_commitments","pitchbook_fund_investment_preferences","pitchbook_fund_investments","pitchbook_fund_performance","pitchbook_fund_search","pitchbook_fund_team","pitchbook_fund_updates","pitchbook_investor_active_investments","pitchbook_investor_bio","pitchbook_investor_board_seats","pitchbook_investor_deal_service_providers","pitchbook_investor_funds","pitchbook_investor_general_service_providers","pitchbook_investor_investments","pitchbook_investor_last_closed_fund","pitchbook_investor_preferences","pitchbook_investor_search","pitchbook_investor_updates","pitchbook_limited_partner_actual_allocations","pitchbook_limited_partner_bio","pitchbook_limited_partner_commitment_aggregates","pitchbook_limited_partner_commitment_preferences","pitchbook_limited_partner_commitments_detailed","pitchbook_limited_partner_search","pitchbook_limited_partner_service_providers","pitchbook_limited_partner_target_allocations","pitchbook_limited_partner_updates","pitchbook_lookup_table_structure","pitchbook_lookup_tables","pitchbook_patent_detailed","pitchbook_patent_search","pitchbook_people_search","pitchbook_person_bio","pitchbook_person_contact","pitchbook_person_education_work","pitchbook_sandbox_entities","pitchbook_search","pitchbook_service_provider_bio","pitchbook_service_provider_search","pitchbook_service_provider_updates","pitchbook_serviced_companies","pitchbook_serviced_deals","pitchbook_serviced_funds","pitchbook_serviced_investors","pitchbook_serviced_limited_partners","pitchbook_shared_search","pitchbook_usage_report","polymarket_get_activity","polymarket_get_event","polymarket_get_events","polymarket_get_holders","polymarket_get_last_trade_price","polymarket_get_leaderboard","polymarket_get_market","polymarket_get_markets","polymarket_get_midpoint","polymarket_get_orderbook","polymarket_get_positions","polymarket_get_price","polymarket_get_price_history","polymarket_get_series","polymarket_get_series_by_id","polymarket_get_spread","polymarket_get_tags","polymarket_get_tick_size","polymarket_get_trades","polymarket_search","postgresql_delete","postgresql_execute","postgresql_insert","postgresql_introspect","postgresql_query","postgresql_update","posthog_batch_events","posthog_capture_event","posthog_create_annotation","posthog_create_cohort","posthog_create_dashboard","posthog_create_experiment","posthog_create_feature_flag","posthog_create_insight","posthog_create_survey","posthog_delete_feature_flag","posthog_delete_person","posthog_delete_survey","posthog_evaluate_flags","posthog_get_cohort","posthog_get_dashboard","posthog_get_event_definition","posthog_get_experiment","posthog_get_feature_flag","posthog_get_insight","posthog_get_organization","posthog_get_person","posthog_get_project","posthog_get_property_definition","posthog_get_session_recording","posthog_get_survey","posthog_list_actions","posthog_list_annotations","posthog_list_cohorts","posthog_list_dashboards","posthog_list_event_definitions","posthog_list_experiments","posthog_list_feature_flags","posthog_list_insights","posthog_list_organizations","posthog_list_persons","posthog_list_projects","posthog_list_property_definitions","posthog_list_recording_playlists","posthog_list_session_recordings","posthog_list_surveys","posthog_query","posthog_update_cohort","posthog_update_event_definition","posthog_update_experiment","posthog_update_feature_flag","posthog_update_insight","posthog_update_property_definition","posthog_update_survey","profound_bot_logs","profound_bots_report","profound_category_assets","profound_category_personas","profound_category_prompts","profound_category_tags","profound_category_topics","profound_citation_prompts","profound_citations_report","profound_list_assets","profound_list_categories","profound_list_domains","profound_list_models","profound_list_optimizations","profound_list_personas","profound_list_regions","profound_optimization_analysis","profound_prompt_answers","profound_prompt_volume","profound_query_fanouts","profound_raw_logs","profound_referrals_report","profound_sentiment_report","profound_visibility_report","prospeo_account_information","prospeo_bulk_enrich_company","prospeo_bulk_enrich_person","prospeo_enrich_company","prospeo_enrich_person","prospeo_search_company","prospeo_search_person","prospeo_search_suggestions","pulse_parser","pulse_parser_v2","qdrant_fetch_points","qdrant_search_vector","qdrant_upsert_points","quartr_get_audio","quartr_get_company","quartr_get_event","quartr_get_event_summary","quartr_get_report","quartr_get_slide_deck","quartr_get_transcript","quartr_list_audio","quartr_list_companies","quartr_list_document_types","quartr_list_documents","quartr_list_event_types","quartr_list_events","quartr_list_live_events","quartr_list_reports","quartr_list_slide_decks","quartr_list_transcripts","quickbooks_add_attachment","quickbooks_create_bill","quickbooks_create_bill_payment","quickbooks_create_credit_memo","quickbooks_create_customer","quickbooks_create_customer_payment","quickbooks_create_deposit","quickbooks_create_employee","quickbooks_create_estimate","quickbooks_create_invoice","quickbooks_create_item","quickbooks_create_journal_entry","quickbooks_create_purchase","quickbooks_create_purchase_order","quickbooks_create_refund_receipt","quickbooks_create_sales_receipt","quickbooks_create_vendor","quickbooks_create_vendor_credit","quickbooks_download_attachment","quickbooks_download_transaction_pdf","quickbooks_email_transaction","quickbooks_get_company_info","quickbooks_read_accounting_transactions","quickbooks_read_attachments","quickbooks_read_master_data","quickbooks_read_purchasing_transactions","quickbooks_read_sales_transactions","quickbooks_run_financial_report","quickbooks_update_bill","quickbooks_update_bill_payment","quickbooks_update_credit_memo","quickbooks_update_customer","quickbooks_update_customer_payment","quickbooks_update_deposit","quickbooks_update_employee","quickbooks_update_estimate","quickbooks_update_invoice","quickbooks_update_item","quickbooks_update_journal_entry","quickbooks_update_purchase","quickbooks_update_purchase_order","quickbooks_update_refund_receipt","quickbooks_update_sales_receipt","quickbooks_update_vendor","quickbooks_update_vendor_credit","quickbooks_void_customer_payment","quickbooks_void_invoice","quiver_image_to_svg","quiver_list_models","quiver_text_to_svg","rabbitmq_create_binding","rabbitmq_create_exchange","rabbitmq_create_policy","rabbitmq_create_queue","rabbitmq_delete_binding","rabbitmq_delete_exchange","rabbitmq_delete_policy","rabbitmq_delete_queue","rabbitmq_get_exchange","rabbitmq_get_messages","rabbitmq_get_overview","rabbitmq_get_queue","rabbitmq_health_check","rabbitmq_list_bindings","rabbitmq_list_channels","rabbitmq_list_connections","rabbitmq_list_consumers","rabbitmq_list_exchange_bindings","rabbitmq_list_exchanges","rabbitmq_list_nodes","rabbitmq_list_policies","rabbitmq_list_queues","rabbitmq_list_vhosts","rabbitmq_publish_message","rabbitmq_purge_queue","railway_create_environment","railway_create_project","railway_create_service","railway_delete_environment","railway_delete_project","railway_delete_service","railway_delete_variable","railway_deploy_service","railway_get_deployment","railway_get_deployment_logs","railway_get_project","railway_list_deployments","railway_list_project_members","railway_list_projects","railway_list_variables","railway_restart_deployment","railway_rollback_deployment","railway_transfer_project","railway_update_project","railway_upsert_variable","rb2b_credit_check","rb2b_email_to_activity","rb2b_hem_to_best_linkedin","rb2b_hem_to_business_profile","rb2b_hem_to_linkedin","rb2b_hem_to_maid","rb2b_ip_to_company","rb2b_ip_to_hem","rb2b_ip_to_maid","rb2b_linkedin_slug_search","rb2b_linkedin_to_best_personal_email","rb2b_linkedin_to_business_profile","rb2b_linkedin_to_hashed_emails","rb2b_linkedin_to_mobile_phone","rb2b_linkedin_to_personal_email","rds_delete","rds_execute","rds_insert","rds_introspect","rds_query","rds_update","reddit_delete","reddit_edit","reddit_get_comments","reddit_get_controversial","reddit_get_info","reddit_get_me","reddit_get_messages","reddit_get_posts","reddit_get_saved","reddit_get_subreddit_info","reddit_get_subreddit_rules","reddit_get_user","reddit_get_user_comments","reddit_get_user_posts","reddit_hide","reddit_hot_posts","reddit_list_my_subreddits","reddit_lock","reddit_mark_all_read","reddit_mark_read","reddit_marknsfw","reddit_mod_approve","reddit_mod_distinguish","reddit_mod_remove","reddit_mod_sticky","reddit_reply","reddit_report","reddit_save","reddit_search","reddit_search_subreddits","reddit_send_message","reddit_submit_post","reddit_subscribe","reddit_unhide","reddit_unlock","reddit_unmarknsfw","reddit_unsave","reddit_vote","redis_command","redis_delete","redis_exists","redis_expire","redis_get","redis_hdel","redis_hget","redis_hgetall","redis_hset","redis_incr","redis_incrby","redis_keys","redis_llen","redis_lpop","redis_lpush","redis_lrange","redis_persist","redis_rpop","redis_rpush","redis_set","redis_setnx","redis_ttl","reducto_parser","reducto_parser_v2","resend_cancel_email","resend_create_audience","resend_create_broadcast","resend_create_contact","resend_delete_audience","resend_delete_contact","resend_get_audience","resend_get_broadcast","resend_get_contact","resend_get_email","resend_list_audiences","resend_list_contacts","resend_list_domains","resend_send","resend_send_broadcast","resend_update_contact","revenuecat_create_purchase","revenuecat_defer_google_subscription","revenuecat_delete_customer","revenuecat_get_customer","revenuecat_grant_entitlement","revenuecat_list_offerings","revenuecat_refund_google_subscription","revenuecat_revoke_entitlement","revenuecat_revoke_google_subscription","revenuecat_update_subscriber_attributes","rippling_bulk_create_custom_object_records","rippling_bulk_delete_custom_object_records","rippling_bulk_update_custom_object_records","rippling_create_business_partner","rippling_create_business_partner_group","rippling_create_custom_app","rippling_create_custom_object","rippling_create_custom_object_field","rippling_create_custom_object_record","rippling_create_custom_page","rippling_create_custom_setting","rippling_create_department","rippling_create_draft_hires","rippling_create_object_category","rippling_create_title","rippling_create_work_location","rippling_delete_business_partner","rippling_delete_business_partner_group","rippling_delete_custom_app","rippling_delete_custom_object","rippling_delete_custom_object_field","rippling_delete_custom_object_record","rippling_delete_custom_page","rippling_delete_custom_setting","rippling_delete_object_category","rippling_delete_title","rippling_delete_work_location","rippling_get_business_partner","rippling_get_business_partner_group","rippling_get_current_user","rippling_get_custom_app","rippling_get_custom_object","rippling_get_custom_object_field","rippling_get_custom_object_record","rippling_get_custom_object_record_by_external_id","rippling_get_custom_page","rippling_get_custom_setting","rippling_get_department","rippling_get_employment_type","rippling_get_job_function","rippling_get_object_category","rippling_get_report_run","rippling_get_supergroup","rippling_get_team","rippling_get_title","rippling_get_user","rippling_get_work_location","rippling_get_worker","rippling_list_business_partner_groups","rippling_list_business_partners","rippling_list_companies","rippling_list_custom_apps","rippling_list_custom_fields","rippling_list_custom_object_fields","rippling_list_custom_object_records","rippling_list_custom_objects","rippling_list_custom_pages","rippling_list_custom_settings","rippling_list_departments","rippling_list_employment_types","rippling_list_entitlements","rippling_list_job_functions","rippling_list_object_categories","rippling_list_supergroup_exclusion_members","rippling_list_supergroup_inclusion_members","rippling_list_supergroup_members","rippling_list_supergroups","rippling_list_teams","rippling_list_titles","rippling_list_users","rippling_list_work_locations","rippling_list_workers","rippling_query_custom_object_records","rippling_trigger_report_run","rippling_update_custom_app","rippling_update_custom_object","rippling_update_custom_object_field","rippling_update_custom_object_record","rippling_update_custom_page","rippling_update_custom_setting","rippling_update_department","rippling_update_object_category","rippling_update_supergroup_exclusion_members","rippling_update_supergroup_inclusion_members","rippling_update_title","rippling_update_work_location","rocketlane_add_field_option","rocketlane_add_project_members","rocketlane_add_task_assignees","rocketlane_add_task_dependencies","rocketlane_add_task_followers","rocketlane_archive_project","rocketlane_assign_placeholders","rocketlane_create_field","rocketlane_create_phase","rocketlane_create_project","rocketlane_create_space","rocketlane_create_space_document","rocketlane_create_task","rocketlane_create_time_entry","rocketlane_create_time_off","rocketlane_delete_field","rocketlane_delete_phase","rocketlane_delete_project","rocketlane_delete_space","rocketlane_delete_space_document","rocketlane_delete_task","rocketlane_delete_time_entry","rocketlane_delete_time_off","rocketlane_get_field","rocketlane_get_invoice","rocketlane_get_invoice_line_items","rocketlane_get_invoice_payments","rocketlane_get_phase","rocketlane_get_project","rocketlane_get_space","rocketlane_get_space_document","rocketlane_get_task","rocketlane_get_time_entry","rocketlane_get_time_off","rocketlane_get_user","rocketlane_import_template","rocketlane_list_fields","rocketlane_list_invoices","rocketlane_list_phases","rocketlane_list_placeholders","rocketlane_list_projects","rocketlane_list_resource_allocations","rocketlane_list_space_documents","rocketlane_list_spaces","rocketlane_list_tasks","rocketlane_list_time_entries","rocketlane_list_time_entry_categories","rocketlane_list_time_offs","rocketlane_list_users","rocketlane_move_task_to_phase","rocketlane_remove_project_members","rocketlane_remove_task_assignees","rocketlane_remove_task_dependencies","rocketlane_remove_task_followers","rocketlane_search_time_entries","rocketlane_unassign_placeholders","rocketlane_update_field","rocketlane_update_field_option","rocketlane_update_phase","rocketlane_update_project","rocketlane_update_space","rocketlane_update_space_document","rocketlane_update_task","rocketlane_update_time_entry","rootly_acknowledge_alert","rootly_add_incident_event","rootly_add_subscribers","rootly_assign_incident_role","rootly_create_action_item","rootly_create_alert","rootly_create_incident","rootly_create_status_page_event","rootly_delete_action_item","rootly_delete_incident","rootly_escalate_alert","rootly_get_alert","rootly_get_incident","rootly_list_action_items","rootly_list_alerts","rootly_list_causes","rootly_list_environments","rootly_list_escalation_policies","rootly_list_functionalities","rootly_list_incident_events","rootly_list_incident_roles","rootly_list_incident_types","rootly_list_incidents","rootly_list_on_calls","rootly_list_playbooks","rootly_list_retrospectives","rootly_list_schedules","rootly_list_services","rootly_list_severities","rootly_list_teams","rootly_list_users","rootly_mitigate_incident","rootly_remove_subscribers","rootly_resolve_alert","rootly_resolve_incident","rootly_run_workflow","rootly_snooze_alert","rootly_unassign_incident_role","rootly_update_action_item","rootly_update_alert","rootly_update_incident","s3_copy_object","s3_create_bucket","s3_delete_bucket","s3_delete_object","s3_delete_objects","s3_get_object","s3_head_object","s3_list_buckets","s3_list_objects","s3_presigned_url","s3_put_object","sailpoint_approve_access_request","sailpoint_cancel_access_request","sailpoint_decide_certification_review_items","sailpoint_get_access_profile","sailpoint_get_access_profile_entitlements","sailpoint_get_access_request_config","sailpoint_get_access_request_status","sailpoint_get_account","sailpoint_get_account_activity","sailpoint_get_account_entitlements","sailpoint_get_account_selections","sailpoint_get_campaign","sailpoint_get_certification","sailpoint_get_entitlement","sailpoint_get_entitlement_request_config","sailpoint_get_identity","sailpoint_get_role","sailpoint_get_role_entitlements","sailpoint_get_source","sailpoint_get_task_status","sailpoint_list_access_profiles","sailpoint_list_account_activities","sailpoint_list_accounts","sailpoint_list_campaigns","sailpoint_list_certification_review_items","sailpoint_list_certifications","sailpoint_list_entitlements","sailpoint_list_identities","sailpoint_list_identity_entitlements","sailpoint_list_pending_access_request_approvals","sailpoint_list_roles","sailpoint_list_sources","sailpoint_load_accounts","sailpoint_load_entitlements","sailpoint_reject_access_request","sailpoint_request_access","sailpoint_search","sailpoint_search_aggregate","sailpoint_search_count","sailpoint_sign_off_certification","salesforce_create_account","salesforce_create_case","salesforce_create_contact","salesforce_create_custom_field","salesforce_create_custom_object","salesforce_create_lead","salesforce_create_opportunity","salesforce_create_task","salesforce_delete_account","salesforce_delete_case","salesforce_delete_contact","salesforce_delete_custom_field","salesforce_delete_lead","salesforce_delete_opportunity","salesforce_delete_task","salesforce_describe_object","salesforce_get_accounts","salesforce_get_cases","salesforce_get_contacts","salesforce_get_dashboard","salesforce_get_leads","salesforce_get_opportunities","salesforce_get_report","salesforce_get_tasks","salesforce_list_dashboards","salesforce_list_objects","salesforce_list_report_types","salesforce_list_reports","salesforce_query","salesforce_query_more","salesforce_refresh_dashboard","salesforce_run_report","salesforce_tooling_query","salesforce_update_account","salesforce_update_case","salesforce_update_contact","salesforce_update_custom_field","salesforce_update_lead","salesforce_update_opportunity","salesforce_update_task","sap_concur_approve_expense_report","sap_concur_associate_attendees","sap_concur_create_cash_advance","sap_concur_create_expected_expense","sap_concur_create_expense_report","sap_concur_create_list_item","sap_concur_create_purchase_request","sap_concur_create_quick_expense","sap_concur_create_quick_expense_with_image","sap_concur_create_report_comment","sap_concur_create_travel_request","sap_concur_create_user","sap_concur_delete_expected_expense","sap_concur_delete_expense","sap_concur_delete_expense_report","sap_concur_delete_list_item","sap_concur_delete_travel_request","sap_concur_delete_user","sap_concur_get_allocation","sap_concur_get_budget","sap_concur_get_cash_advance","sap_concur_get_expected_expense","sap_concur_get_expense","sap_concur_get_expense_report","sap_concur_get_itemizations","sap_concur_get_itinerary","sap_concur_get_list","sap_concur_get_list_item","sap_concur_get_purchase_request","sap_concur_get_receipt","sap_concur_get_receipt_status","sap_concur_get_request_cash_advance","sap_concur_get_travel_profile","sap_concur_get_travel_request","sap_concur_get_user","sap_concur_issue_cash_advance","sap_concur_list_allocations","sap_concur_list_attendee_associations","sap_concur_list_budget_categories","sap_concur_list_budgets","sap_concur_list_exceptions","sap_concur_list_expected_expenses","sap_concur_list_expense_reports","sap_concur_list_expenses","sap_concur_list_itineraries","sap_concur_list_list_items","sap_concur_list_lists","sap_concur_list_receipts","sap_concur_list_report_comments","sap_concur_list_reports_to_approve","sap_concur_list_travel_profiles_summary","sap_concur_list_travel_request_comments","sap_concur_list_travel_requests","sap_concur_list_users","sap_concur_move_travel_request","sap_concur_recall_expense_report","sap_concur_remove_all_attendees","sap_concur_search_locations","sap_concur_search_users","sap_concur_send_back_expense_report","sap_concur_submit_expense_report","sap_concur_update_allocation","sap_concur_update_expected_expense","sap_concur_update_expense","sap_concur_update_expense_report","sap_concur_update_list_item","sap_concur_update_travel_request","sap_concur_update_user","sap_concur_upload_exchange_rates","sap_concur_upload_receipt_image","sap_s4hana_create_business_partner","sap_s4hana_create_purchase_order","sap_s4hana_create_purchase_requisition","sap_s4hana_create_sales_order","sap_s4hana_delete_sales_order","sap_s4hana_get_billing_document","sap_s4hana_get_business_partner","sap_s4hana_get_customer","sap_s4hana_get_inbound_delivery","sap_s4hana_get_material_document","sap_s4hana_get_outbound_delivery","sap_s4hana_get_product","sap_s4hana_get_purchase_order","sap_s4hana_get_purchase_requisition","sap_s4hana_get_sales_order","sap_s4hana_get_supplier","sap_s4hana_get_supplier_invoice","sap_s4hana_list_billing_documents","sap_s4hana_list_business_partners","sap_s4hana_list_customers","sap_s4hana_list_inbound_deliveries","sap_s4hana_list_material_documents","sap_s4hana_list_material_stock","sap_s4hana_list_outbound_deliveries","sap_s4hana_list_products","sap_s4hana_list_purchase_orders","sap_s4hana_list_purchase_requisitions","sap_s4hana_list_sales_orders","sap_s4hana_list_supplier_invoices","sap_s4hana_list_suppliers","sap_s4hana_odata_query","sap_s4hana_update_business_partner","sap_s4hana_update_customer","sap_s4hana_update_product","sap_s4hana_update_purchase_order","sap_s4hana_update_purchase_requisition","sap_s4hana_update_sales_order","sap_s4hana_update_supplier","search_tool","secrets_manager_create_secret","secrets_manager_delete_secret","secrets_manager_describe_secret","secrets_manager_get_secret","secrets_manager_list_secrets","secrets_manager_restore_secret","secrets_manager_rotate_secret","secrets_manager_tag_resource","secrets_manager_untag_resource","secrets_manager_update_secret","semrush_backlinks","semrush_backlinks_anchors","semrush_backlinks_competitors","semrush_backlinks_geo_distribution","semrush_backlinks_indexed_pages","semrush_backlinks_overview","semrush_backlinks_tld_distribution","semrush_batch_keyword_overview","semrush_broad_match_keywords","semrush_domain_ad_copies","semrush_domain_ad_history","semrush_domain_organic_competitors","semrush_domain_organic_keywords","semrush_domain_overview","semrush_domain_overview_all","semrush_domain_overview_history","semrush_domain_paid_competitors","semrush_domain_paid_keywords","semrush_domain_pla_copies","semrush_domain_pla_keywords","semrush_domain_vs_domain","semrush_keyword_ad_history","semrush_keyword_difficulty","semrush_keyword_overview","semrush_keyword_overview_all","semrush_keyword_questions","semrush_organic_results","semrush_paid_results","semrush_referring_domains","semrush_referring_ips","semrush_related_keywords","semrush_subdomain_ad_copies","semrush_subdomain_organic_keywords","semrush_subdomain_overview","semrush_subdomain_overview_all","semrush_subdomain_overview_history","semrush_subdomain_paid_keywords","semrush_top_domains","semrush_url_organic_keywords","semrush_url_overview","semrush_url_overview_all","semrush_url_overview_history","semrush_url_paid_keywords","semrush_winners_and_losers","sendblue_evaluate_service","sendblue_get_message","sendblue_send_group_message","sendblue_send_message","sendblue_send_typing_indicator","sendgrid_add_contact","sendgrid_add_contacts_to_list","sendgrid_create_list","sendgrid_create_template","sendgrid_create_template_version","sendgrid_delete_contacts","sendgrid_delete_list","sendgrid_delete_template","sendgrid_get_contact","sendgrid_get_list","sendgrid_get_template","sendgrid_list_all_lists","sendgrid_list_templates","sendgrid_remove_contacts_from_list","sendgrid_search_contacts","sendgrid_send_mail","sentry_events_get","sentry_events_list","sentry_issues_get","sentry_issues_list","sentry_issues_update","sentry_projects_create","sentry_projects_get","sentry_projects_list","sentry_projects_update","sentry_releases_create","sentry_releases_deploy","sentry_releases_list","sentry_teams_list","serper_search","servicenow_add_incident_comment","servicenow_aggregate","servicenow_close_incident","servicenow_create_change_request","servicenow_create_incident","servicenow_create_record","servicenow_delete_record","servicenow_download_attachment","servicenow_find_user","servicenow_get_change_next_states","servicenow_get_change_request","servicenow_get_ci","servicenow_get_incident","servicenow_get_knowledge_article","servicenow_get_requested_item","servicenow_list_approvals","servicenow_list_attachments","servicenow_list_catalog_items","servicenow_list_change_requests","servicenow_list_change_tasks","servicenow_list_ci_relationships","servicenow_list_group_members","servicenow_list_incidents","servicenow_list_requested_items","servicenow_order_catalog_item","servicenow_read_record","servicenow_resolve_incident","servicenow_search_cis","servicenow_search_knowledge","servicenow_update_approval","servicenow_update_change_request","servicenow_update_change_state","servicenow_update_incident","servicenow_update_record","servicenow_upload_attachment","ses_create_configuration_set","ses_create_email_identity","ses_create_template","ses_delete_email_identity","ses_delete_suppressed_destination","ses_delete_template","ses_get_account","ses_get_email_identity","ses_get_suppressed_destination","ses_get_template","ses_list_identities","ses_list_suppressed_destinations","ses_list_templates","ses_put_suppressed_destination","ses_send_bulk_email","ses_send_custom_verification_email","ses_send_email","ses_send_templated_email","ses_update_template","sftp_delete","sftp_download","sftp_list","sftp_mkdir","sftp_upload","sharepoint_add_list_items","sharepoint_create_list","sharepoint_create_page","sharepoint_delete_file","sharepoint_delete_list_item","sharepoint_delete_page","sharepoint_download_file","sharepoint_get_drive_item","sharepoint_get_list","sharepoint_get_list_item","sharepoint_list_sites","sharepoint_publish_page","sharepoint_read_page","sharepoint_update_list","sharepoint_update_page","sharepoint_upload_file","shopify_adjust_inventory","shopify_cancel_order","shopify_create_customer","shopify_create_fulfillment","shopify_create_product","shopify_delete_customer","shopify_delete_product","shopify_get_collection","shopify_get_customer","shopify_get_inventory_level","shopify_get_order","shopify_get_product","shopify_list_collections","shopify_list_customers","shopify_list_inventory_items","shopify_list_locations","shopify_list_orders","shopify_list_products","shopify_update_customer","shopify_update_order","shopify_update_product","similarweb_bounce_rate","similarweb_page_views","similarweb_pages_per_visit","similarweb_traffic_visits","similarweb_visit_duration","similarweb_website_overview","sixtyfour_enrich_company","sixtyfour_enrich_lead","sixtyfour_find_email","sixtyfour_find_phone","slack_add_reaction","slack_archive_conversation","slack_canvas","slack_create_channel_canvas","slack_create_conversation","slack_delete_canvas","slack_delete_message","slack_delete_scheduled_message","slack_download","slack_edit_canvas","slack_ephemeral_message","slack_get_canvas","slack_get_channel_history","slack_get_channel_info","slack_get_message","slack_get_permalink","slack_get_thread","slack_get_thread_replies","slack_get_user","slack_get_user_presence","slack_invite_to_conversation","slack_list_canvases","slack_list_channels","slack_list_members","slack_list_scheduled_messages","slack_list_users","slack_lookup_canvas_sections","slack_message","slack_message_reader","slack_open_view","slack_publish_view","slack_push_view","slack_remove_reaction","slack_rename_agent_session_v2","slack_rename_conversation","slack_schedule_message","slack_set_agent_session_status_v2","slack_set_conversation_purpose","slack_set_conversation_topic","slack_set_status","slack_set_suggested_prompts","slack_set_suggested_prompts_v2","slack_set_title","slack_update_message","slack_update_view","smartlead_add_email_accounts_to_campaign","smartlead_add_leads_to_campaign","smartlead_create_campaign","smartlead_create_lead_list","smartlead_delete_campaign","smartlead_delete_campaign_webhook","smartlead_delete_lead_from_campaign","smartlead_delete_lead_list","smartlead_duplicate_campaign","smartlead_export_campaign_leads","smartlead_get_campaign","smartlead_get_campaign_analytics","smartlead_get_campaign_analytics_by_date","smartlead_get_campaign_lead_statistics","smartlead_get_campaign_mailbox_statistics","smartlead_get_campaign_sequences","smartlead_get_campaign_statistics","smartlead_get_campaign_top_level_analytics_by_date","smartlead_get_campaign_webhook_summary","smartlead_get_lead_by_email","smartlead_get_lead_by_id","smartlead_get_lead_list","smartlead_get_lead_message_history","smartlead_list_campaign_email_accounts","smartlead_list_campaign_leads","smartlead_list_campaign_webhooks","smartlead_list_campaigns","smartlead_list_clients","smartlead_list_email_accounts","smartlead_list_inbox_replies","smartlead_list_lead_activities","smartlead_list_lead_categories","smartlead_list_lead_lists","smartlead_mark_lead_complete","smartlead_pause_lead","smartlead_remove_email_accounts_from_campaign","smartlead_resume_lead","smartlead_save_campaign_sequences","smartlead_unsubscribe_lead_from_campaign","smartlead_unsubscribe_lead_globally","smartlead_update_campaign_schedule","smartlead_update_campaign_settings","smartlead_update_campaign_status","smartlead_update_lead","smartlead_update_lead_category","smartlead_update_lead_list","smartlead_upsert_campaign_webhook","sms_send","smtp_send_mail","snowflake_alter_warehouse","snowflake_call_procedure","snowflake_cancel_statement","snowflake_cancel_task_run","snowflake_delete_rows","snowflake_execute_sql","snowflake_get_statement","snowflake_get_task","snowflake_get_task_run","snowflake_get_task_run_output","snowflake_get_warehouse","snowflake_insert_rows","snowflake_introspect_schema","snowflake_list_copy_history","snowflake_list_databases","snowflake_list_query_history","snowflake_list_schemas","snowflake_list_tables","snowflake_list_task_runs","snowflake_list_tasks","snowflake_list_warehouses","snowflake_load_data","snowflake_resume_task","snowflake_resume_warehouse","snowflake_run_task","snowflake_suspend_task","snowflake_suspend_warehouse","snowflake_unload_data","snowflake_update_rows","snowflake_upsert_rows","splunk_cancel_search_job","splunk_create_search_job","splunk_dispatch_saved_search","splunk_get_fired_alerts","splunk_get_saved_search","splunk_get_search_job","splunk_get_search_results","splunk_list_apps","splunk_list_fired_alerts","splunk_list_indexes","splunk_list_saved_searches","splunk_run_search","sportmonks_core_get_cities","sportmonks_core_get_city","sportmonks_core_get_continent","sportmonks_core_get_continents","sportmonks_core_get_countries","sportmonks_core_get_country","sportmonks_core_get_entity_filters","sportmonks_core_get_my_usage","sportmonks_core_get_region","sportmonks_core_get_regions","sportmonks_core_get_timezones","sportmonks_core_get_type","sportmonks_core_get_type_by_entity","sportmonks_core_get_types","sportmonks_core_search_cities","sportmonks_core_search_countries","sportmonks_core_search_regions","sportmonks_football_expected_by_player","sportmonks_football_expected_by_team","sportmonks_football_get_all_commentaries","sportmonks_football_get_all_fixtures","sportmonks_football_get_all_players","sportmonks_football_get_all_rivals","sportmonks_football_get_all_teams","sportmonks_football_get_all_transfer_rumours","sportmonks_football_get_all_transfers","sportmonks_football_get_brackets_by_season","sportmonks_football_get_coach","sportmonks_football_get_coaches","sportmonks_football_get_coaches_by_country","sportmonks_football_get_commentaries_by_fixture","sportmonks_football_get_current_leagues_by_team","sportmonks_football_get_expected_lineups_by_player","sportmonks_football_get_expected_lineups_by_team","sportmonks_football_get_extended_team_squad","sportmonks_football_get_fixture","sportmonks_football_get_fixtures_by_date","sportmonks_football_get_fixtures_by_date_range","sportmonks_football_get_fixtures_by_date_range_for_team","sportmonks_football_get_fixtures_by_ids","sportmonks_football_get_grouped_standings_by_round","sportmonks_football_get_head_to_head","sportmonks_football_get_inplay_livescores","sportmonks_football_get_latest_coaches","sportmonks_football_get_latest_fixtures","sportmonks_football_get_latest_livescores","sportmonks_football_get_latest_players","sportmonks_football_get_latest_totw","sportmonks_football_get_latest_transfers","sportmonks_football_get_league","sportmonks_football_get_leagues","sportmonks_football_get_leagues_by_country","sportmonks_football_get_leagues_by_date","sportmonks_football_get_leagues_by_team","sportmonks_football_get_live_leagues","sportmonks_football_get_live_probabilities","sportmonks_football_get_live_probabilities_by_fixture","sportmonks_football_get_live_standings_by_league","sportmonks_football_get_livescores","sportmonks_football_get_match_facts","sportmonks_football_get_match_facts_by_date_range","sportmonks_football_get_match_facts_by_fixture","sportmonks_football_get_match_facts_by_league","sportmonks_football_get_past_fixtures_by_tv_station","sportmonks_football_get_player","sportmonks_football_get_players_by_country","sportmonks_football_get_postmatch_news","sportmonks_football_get_postmatch_news_by_season","sportmonks_football_get_predictability_by_league","sportmonks_football_get_prematch_news","sportmonks_football_get_prematch_news_by_season","sportmonks_football_get_prematch_news_upcoming","sportmonks_football_get_probabilities","sportmonks_football_get_probabilities_by_fixture","sportmonks_football_get_referee","sportmonks_football_get_referees","sportmonks_football_get_referees_by_country","sportmonks_football_get_referees_by_season","sportmonks_football_get_rivals_by_team","sportmonks_football_get_round","sportmonks_football_get_round_statistics","sportmonks_football_get_rounds","sportmonks_football_get_rounds_by_season","sportmonks_football_get_schedules_by_season","sportmonks_football_get_schedules_by_season_and_team","sportmonks_football_get_schedules_by_team","sportmonks_football_get_season","sportmonks_football_get_seasons","sportmonks_football_get_seasons_by_team","sportmonks_football_get_stage","sportmonks_football_get_stage_statistics","sportmonks_football_get_stages","sportmonks_football_get_stages_by_season","sportmonks_football_get_standing_corrections_by_season","sportmonks_football_get_standings","sportmonks_football_get_standings_by_round","sportmonks_football_get_standings_by_season","sportmonks_football_get_state","sportmonks_football_get_states","sportmonks_football_get_team","sportmonks_football_get_team_rankings","sportmonks_football_get_team_rankings_by_date","sportmonks_football_get_team_rankings_by_team","sportmonks_football_get_team_squad","sportmonks_football_get_team_squad_by_season","sportmonks_football_get_teams_by_country","sportmonks_football_get_teams_by_season","sportmonks_football_get_topscorers_by_season","sportmonks_football_get_topscorers_by_stage","sportmonks_football_get_totw","sportmonks_football_get_totw_by_round","sportmonks_football_get_transfer","sportmonks_football_get_transfer_rumour","sportmonks_football_get_transfer_rumours_between_dates","sportmonks_football_get_transfer_rumours_by_player","sportmonks_football_get_transfer_rumours_by_team","sportmonks_football_get_transfers_between_dates","sportmonks_football_get_transfers_by_player","sportmonks_football_get_transfers_by_team","sportmonks_football_get_tv_station","sportmonks_football_get_tv_stations","sportmonks_football_get_tv_stations_by_fixture","sportmonks_football_get_upcoming_fixtures_by_market","sportmonks_football_get_upcoming_fixtures_by_tv_station","sportmonks_football_get_value_bets","sportmonks_football_get_value_bets_by_fixture","sportmonks_football_get_venue","sportmonks_football_get_venues","sportmonks_football_get_venues_by_season","sportmonks_football_search_coaches","sportmonks_football_search_fixtures","sportmonks_football_search_leagues","sportmonks_football_search_players","sportmonks_football_search_referees","sportmonks_football_search_rounds","sportmonks_football_search_seasons","sportmonks_football_search_stages","sportmonks_football_search_teams","sportmonks_football_search_venues","sportmonks_motorsport_get_all_fixtures","sportmonks_motorsport_get_current_leagues_by_team","sportmonks_motorsport_get_driver","sportmonks_motorsport_get_driver_standings","sportmonks_motorsport_get_driver_standings_by_season","sportmonks_motorsport_get_drivers","sportmonks_motorsport_get_drivers_by_country","sportmonks_motorsport_get_drivers_by_season","sportmonks_motorsport_get_fixture","sportmonks_motorsport_get_fixtures_by_date","sportmonks_motorsport_get_fixtures_by_date_range","sportmonks_motorsport_get_fixtures_by_ids","sportmonks_motorsport_get_laps_by_fixture","sportmonks_motorsport_get_laps_by_fixture_and_driver","sportmonks_motorsport_get_laps_by_fixture_and_lap","sportmonks_motorsport_get_latest_laps_by_fixture","sportmonks_motorsport_get_latest_pitstops_by_fixture","sportmonks_motorsport_get_latest_stints_by_fixture","sportmonks_motorsport_get_latest_updated_drivers","sportmonks_motorsport_get_latest_updated_fixtures","sportmonks_motorsport_get_league","sportmonks_motorsport_get_leagues","sportmonks_motorsport_get_leagues_by_country","sportmonks_motorsport_get_leagues_by_date","sportmonks_motorsport_get_leagues_by_live","sportmonks_motorsport_get_leagues_by_team","sportmonks_motorsport_get_livescores","sportmonks_motorsport_get_pitstops_by_fixture","sportmonks_motorsport_get_pitstops_by_fixture_and_driver","sportmonks_motorsport_get_pitstops_by_fixture_and_lap","sportmonks_motorsport_get_race_results_by_season_and_driver","sportmonks_motorsport_get_race_results_by_season_and_team","sportmonks_motorsport_get_schedules_by_season","sportmonks_motorsport_get_season","sportmonks_motorsport_get_seasons","sportmonks_motorsport_get_stage","sportmonks_motorsport_get_stages","sportmonks_motorsport_get_stages_by_season","sportmonks_motorsport_get_state","sportmonks_motorsport_get_states","sportmonks_motorsport_get_stints_by_fixture","sportmonks_motorsport_get_stints_by_fixture_and_driver","sportmonks_motorsport_get_stints_by_fixture_and_stint","sportmonks_motorsport_get_team","sportmonks_motorsport_get_team_standings","sportmonks_motorsport_get_team_standings_by_season","sportmonks_motorsport_get_teams","sportmonks_motorsport_get_teams_by_country","sportmonks_motorsport_get_teams_by_season","sportmonks_motorsport_get_venue","sportmonks_motorsport_get_venues","sportmonks_motorsport_get_venues_by_season","sportmonks_motorsport_search_drivers","sportmonks_motorsport_search_leagues","sportmonks_motorsport_search_stages","sportmonks_motorsport_search_teams","sportmonks_motorsport_search_venues","sportmonks_odds_get_all_historical_odds","sportmonks_odds_get_all_inplay_odds","sportmonks_odds_get_all_pre_match_odds","sportmonks_odds_get_all_premium_odds","sportmonks_odds_get_bookmaker","sportmonks_odds_get_bookmaker_event_ids_by_fixture","sportmonks_odds_get_bookmakers","sportmonks_odds_get_bookmakers_by_fixture","sportmonks_odds_get_inplay_odds_by_fixture","sportmonks_odds_get_inplay_odds_by_fixture_and_bookmaker","sportmonks_odds_get_inplay_odds_by_fixture_and_market","sportmonks_odds_get_last_updated_inplay_odds","sportmonks_odds_get_last_updated_pre_match_odds","sportmonks_odds_get_market","sportmonks_odds_get_markets","sportmonks_odds_get_pre_match_odds_by_fixture","sportmonks_odds_get_pre_match_odds_by_fixture_and_bookmaker","sportmonks_odds_get_pre_match_odds_by_fixture_and_market","sportmonks_odds_get_premium_odds_by_fixture","sportmonks_odds_get_premium_odds_by_fixture_and_bookmaker","sportmonks_odds_get_premium_odds_by_fixture_and_market","sportmonks_odds_get_updated_historical_odds_between","sportmonks_odds_get_updated_premium_odds_between","sportmonks_odds_search_bookmakers","sportmonks_odds_search_markets","spotify_add_playlist_cover","spotify_add_to_queue","spotify_add_tracks_to_playlist","spotify_check_following","spotify_check_playlist_followers","spotify_check_saved_albums","spotify_check_saved_audiobooks","spotify_check_saved_episodes","spotify_check_saved_shows","spotify_check_saved_tracks","spotify_create_playlist","spotify_follow_artists","spotify_follow_playlist","spotify_get_album","spotify_get_album_tracks","spotify_get_albums","spotify_get_artist","spotify_get_artist_albums","spotify_get_artist_top_tracks","spotify_get_artists","spotify_get_audiobook","spotify_get_audiobook_chapters","spotify_get_audiobooks","spotify_get_categories","spotify_get_current_user","spotify_get_currently_playing","spotify_get_devices","spotify_get_episode","spotify_get_episodes","spotify_get_followed_artists","spotify_get_markets","spotify_get_new_releases","spotify_get_playback_state","spotify_get_playlist","spotify_get_playlist_cover","spotify_get_playlist_tracks","spotify_get_queue","spotify_get_recently_played","spotify_get_saved_albums","spotify_get_saved_audiobooks","spotify_get_saved_episodes","spotify_get_saved_shows","spotify_get_saved_tracks","spotify_get_show","spotify_get_show_episodes","spotify_get_shows","spotify_get_top_artists","spotify_get_top_tracks","spotify_get_track","spotify_get_tracks","spotify_get_user_playlists","spotify_get_user_profile","spotify_pause","spotify_play","spotify_remove_saved_albums","spotify_remove_saved_audiobooks","spotify_remove_saved_episodes","spotify_remove_saved_shows","spotify_remove_saved_tracks","spotify_remove_tracks_from_playlist","spotify_reorder_playlist_items","spotify_replace_playlist_items","spotify_save_albums","spotify_save_audiobooks","spotify_save_episodes","spotify_save_shows","spotify_save_tracks","spotify_search","spotify_seek","spotify_set_repeat","spotify_set_shuffle","spotify_set_volume","spotify_skip_next","spotify_skip_previous","spotify_transfer_playback","spotify_unfollow_artists","spotify_unfollow_playlist","spotify_update_playlist","sqs_cancel_message_move_task","sqs_change_message_visibility","sqs_change_message_visibility_batch","sqs_create_queue","sqs_delete_message","sqs_delete_message_batch","sqs_delete_queue","sqs_get_queue_attributes","sqs_get_queue_url","sqs_list_dead_letter_source_queues","sqs_list_message_move_tasks","sqs_list_queue_tags","sqs_list_queues","sqs_purge_queue","sqs_receive_message","sqs_send","sqs_send_message_batch","sqs_set_queue_attributes","sqs_start_message_move_task","sqs_tag_queue","sqs_untag_queue","square_batch_retrieve_inventory_counts","square_cancel_invoice","square_cancel_payment","square_complete_payment","square_create_catalog_image","square_create_customer","square_create_invoice","square_create_order","square_create_payment","square_delete_catalog_object","square_delete_customer","square_delete_invoice","square_get_catalog_object","square_get_customer","square_get_invoice","square_get_location","square_get_order","square_get_payment","square_get_refund","square_list_catalog","square_list_customers","square_list_invoices","square_list_locations","square_list_payments","square_list_refunds","square_pay_order","square_publish_invoice","square_refund_payment","square_search_catalog_objects","square_search_customers","square_search_invoices","square_search_orders","square_update_customer","square_upsert_catalog_object","ssh_check_command_exists","ssh_check_file_exists","ssh_create_directory","ssh_delete_file","ssh_download_file","ssh_execute_command","ssh_execute_script","ssh_get_system_info","ssh_list_directory","ssh_move_rename","ssh_read_file_content","ssh_upload_file","ssh_write_file_content","ssm_cancel_command","ssm_delete_parameter","ssm_describe_automation_executions","ssm_describe_instance_information","ssm_describe_instance_patch_states","ssm_describe_instance_patches","ssm_describe_parameters","ssm_get_automation_execution","ssm_get_command_invocation","ssm_get_document","ssm_get_parameter","ssm_get_parameters","ssm_get_parameters_by_path","ssm_list_command_invocations","ssm_list_commands","ssm_list_compliance_items","ssm_list_compliance_summaries","ssm_list_documents","ssm_put_parameter","ssm_send_command","ssm_start_automation_execution","ssm_stop_automation_execution","stagehand_agent","stagehand_extract","stripe_cancel_payment_intent","stripe_cancel_subscription","stripe_capture_charge","stripe_capture_payment_intent","stripe_confirm_payment_intent","stripe_create_charge","stripe_create_customer","stripe_create_invoice","stripe_create_payment_intent","stripe_create_price","stripe_create_product","stripe_create_subscription","stripe_delete_customer","stripe_delete_invoice","stripe_delete_product","stripe_finalize_invoice","stripe_list_charges","stripe_list_customers","stripe_list_events","stripe_list_invoices","stripe_list_payment_intents","stripe_list_prices","stripe_list_products","stripe_list_subscriptions","stripe_pay_invoice","stripe_resume_subscription","stripe_retrieve_charge","stripe_retrieve_customer","stripe_retrieve_event","stripe_retrieve_invoice","stripe_retrieve_payment_intent","stripe_retrieve_price","stripe_retrieve_product","stripe_retrieve_subscription","stripe_search_charges","stripe_search_customers","stripe_search_invoices","stripe_search_payment_intents","stripe_search_prices","stripe_search_products","stripe_search_subscriptions","stripe_send_invoice","stripe_update_charge","stripe_update_customer","stripe_update_invoice","stripe_update_payment_intent","stripe_update_price","stripe_update_product","stripe_update_subscription","stripe_void_invoice","sts_assume_role","sts_assume_role_with_saml","sts_assume_role_with_web_identity","sts_get_access_key_info","sts_get_caller_identity","sts_get_session_token","stt_assemblyai","stt_assemblyai_v2","stt_deepgram","stt_deepgram_v2","stt_elevenlabs","stt_elevenlabs_v2","stt_gemini","stt_gemini_v2","stt_whisper","stt_whisper_v2","supabase_count","supabase_delete","supabase_get_row","supabase_insert","supabase_introspect","supabase_invoke_function","supabase_query","supabase_rpc","supabase_storage_copy","supabase_storage_create_bucket","supabase_storage_create_signed_upload_url","supabase_storage_create_signed_url","supabase_storage_delete","supabase_storage_delete_bucket","supabase_storage_download","supabase_storage_empty_bucket","supabase_storage_get_public_url","supabase_storage_list","supabase_storage_list_buckets","supabase_storage_move","supabase_storage_update_bucket","supabase_storage_upload","supabase_text_search","supabase_update","supabase_upsert","supabase_vector_search","table_batch_insert_rows","table_create","table_delete_row","table_delete_rows_by_filter","table_get_row","table_get_schema","table_insert_row","table_list","table_query_rows","table_query_rows_v2","table_update_row","table_update_rows_by_filter","table_upsert_row","tailscale_authorize_device","tailscale_create_auth_key","tailscale_delete_auth_key","tailscale_delete_device","tailscale_delete_user","tailscale_expire_device_key","tailscale_get_acl","tailscale_get_auth_key","tailscale_get_device","tailscale_get_device_routes","tailscale_get_dns_preferences","tailscale_get_dns_searchpaths","tailscale_list_auth_keys","tailscale_list_devices","tailscale_list_dns_nameservers","tailscale_list_users","tailscale_set_acl","tailscale_set_device_routes","tailscale_set_device_tags","tailscale_set_dns_nameservers","tailscale_set_dns_preferences","tailscale_set_dns_searchpaths","tailscale_suspend_user","tailscale_update_device_key","tavily_crawl","tavily_extract","tavily_map","tavily_search","telegram_copy_message","telegram_delete_message","telegram_edit_message_text","telegram_forward_message","telegram_get_chat","telegram_get_chat_member","telegram_message","telegram_pin_message","telegram_send_animation","telegram_send_audio","telegram_send_chat_action","telegram_send_contact","telegram_send_document","telegram_send_location","telegram_send_photo","telegram_send_poll","telegram_send_video","telegram_set_message_reaction","telegram_unpin_message","temporal_cancel_workflow","temporal_count_workflows","temporal_create_schedule","temporal_delete_schedule","temporal_describe_schedule","temporal_describe_task_queue","temporal_describe_workflow","temporal_get_workflow_history","temporal_list_schedules","temporal_list_workflows","temporal_pause_schedule","temporal_query_workflow","temporal_reset_workflow","temporal_signal_with_start","temporal_signal_workflow","temporal_start_workflow","temporal_terminate_workflow","temporal_trigger_schedule","temporal_unpause_schedule","temporal_update_workflow","textract_analyze_expense","textract_analyze_id","textract_parser","textract_parser_v2","thinking_tool","thrive_add_audience_managers","thrive_add_audience_members","thrive_add_user_tags","thrive_create_assignment","thrive_create_audience","thrive_create_completion","thrive_create_user","thrive_delete_assignment","thrive_delete_audience","thrive_delete_user","thrive_get_activity","thrive_get_assignment","thrive_get_audience","thrive_get_completion","thrive_get_content","thrive_get_cpd_category","thrive_get_cpd_entry","thrive_get_cpd_requirement","thrive_get_enrolment","thrive_get_skill_levels","thrive_get_tag","thrive_get_user_by_id","thrive_get_user_by_ref","thrive_list_assignments","thrive_list_audience_managers","thrive_list_audience_members","thrive_list_audiences","thrive_list_completions","thrive_list_enrolments","thrive_list_tags","thrive_query_activities","thrive_query_content","thrive_query_cpd_categories","thrive_query_cpd_entries","thrive_query_cpd_requirements","thrive_query_cpd_user_summaries","thrive_remove_audience_manager","thrive_remove_audience_member","thrive_remove_user_tags","thrive_replace_audience_managers","thrive_replace_audience_members","thrive_search_users","thrive_suspend_user","thrive_update_assignment","thrive_update_audience","thrive_update_user","thrive_update_user_skills","tiktok_get_post_status","tiktok_get_user","tiktok_list_videos","tiktok_query_videos","tiktok_upload_video_draft","tinybird_append_datasource","tinybird_delete_datasource_rows","tinybird_events","tinybird_get_job","tinybird_query","tinybird_query_pipe","tinybird_truncate_datasource","tinyfish_cancel_run","tinyfish_fetch","tinyfish_get_run","tinyfish_list_profiles","tinyfish_list_runs","tinyfish_list_vault_items","tinyfish_run","tinyfish_run_async","tinyfish_search","trello_add_checklist","trello_add_checklist_item","trello_add_comment","trello_add_label","trello_add_member","trello_create_board","trello_create_card","trello_create_list","trello_delete_card","trello_get_actions","trello_get_board","trello_get_card","trello_list_cards","trello_list_lists","trello_list_members","trello_remove_label","trello_remove_member","trello_search","trello_update_card","trello_update_checklist_item","trello_update_list","trigger_dev_activate_schedule","trigger_dev_add_run_tags","trigger_dev_batch_trigger_task","trigger_dev_cancel_run","trigger_dev_complete_waitpoint_token","trigger_dev_create_env_var","trigger_dev_create_schedule","trigger_dev_create_waitpoint_token","trigger_dev_deactivate_schedule","trigger_dev_delete_env_var","trigger_dev_delete_schedule","trigger_dev_execute_query","trigger_dev_get_batch","trigger_dev_get_batch_results","trigger_dev_get_deployment","trigger_dev_get_env_var","trigger_dev_get_latest_deployment","trigger_dev_get_query_schema","trigger_dev_get_queue","trigger_dev_get_run","trigger_dev_get_run_events","trigger_dev_get_run_result","trigger_dev_get_run_trace","trigger_dev_get_schedule","trigger_dev_get_waitpoint_token","trigger_dev_import_env_vars","trigger_dev_list_deployments","trigger_dev_list_env_vars","trigger_dev_list_queues","trigger_dev_list_runs","trigger_dev_list_schedules","trigger_dev_list_timezones","trigger_dev_list_waitpoint_tokens","trigger_dev_override_queue_concurrency","trigger_dev_pause_queue","trigger_dev_promote_deployment","trigger_dev_replay_run","trigger_dev_reschedule_run","trigger_dev_reset_queue_concurrency","trigger_dev_resume_queue","trigger_dev_trigger_task","trigger_dev_update_env_var","trigger_dev_update_run_metadata","trigger_dev_update_schedule","tts_azure","tts_cartesia","tts_deepgram","tts_elevenlabs","tts_google","tts_openai","tts_playht","twilio_send_sms","twilio_voice_get_recording","twilio_voice_list_calls","twilio_voice_make_call","typeform_create_form","typeform_delete_form","typeform_files","typeform_get_form","typeform_insights","typeform_list_forms","typeform_responses","typeform_update_form","upstash_redis_command","upstash_redis_delete","upstash_redis_exists","upstash_redis_expire","upstash_redis_get","upstash_redis_hget","upstash_redis_hgetall","upstash_redis_hset","upstash_redis_incr","upstash_redis_incrby","upstash_redis_keys","upstash_redis_lpush","upstash_redis_lrange","upstash_redis_set","upstash_redis_setnx","upstash_redis_ttl","uptimerobot_create_alert_contact","uptimerobot_create_maintenance_window","uptimerobot_create_monitor","uptimerobot_create_psp","uptimerobot_delete_alert_contact","uptimerobot_delete_maintenance_window","uptimerobot_delete_monitor","uptimerobot_delete_psp","uptimerobot_get_account","uptimerobot_get_alert_contact","uptimerobot_get_incident","uptimerobot_get_maintenance_window","uptimerobot_get_monitor","uptimerobot_get_psp","uptimerobot_list_alert_contacts","uptimerobot_list_incidents","uptimerobot_list_maintenance_windows","uptimerobot_list_monitors","uptimerobot_list_psps","uptimerobot_pause_monitor","uptimerobot_start_monitor","uptimerobot_update_maintenance_window","uptimerobot_update_monitor","uptimerobot_update_psp","vanta_download_document_file","vanta_get_control","vanta_get_document","vanta_get_framework","vanta_get_person","vanta_get_policy","vanta_get_risk_scenario","vanta_get_test","vanta_get_vendor","vanta_get_vulnerable_asset","vanta_list_control_documents","vanta_list_control_tests","vanta_list_controls","vanta_list_document_uploads","vanta_list_documents","vanta_list_framework_controls","vanta_list_frameworks","vanta_list_monitored_computers","vanta_list_people","vanta_list_policies","vanta_list_risk_scenarios","vanta_list_test_entities","vanta_list_tests","vanta_list_vendors","vanta_list_vulnerabilities","vanta_list_vulnerability_remediations","vanta_list_vulnerable_assets","vanta_submit_document","vanta_upload_document_file","vercel_add_domain","vercel_add_project_domain","vercel_cancel_deployment","vercel_create_alias","vercel_create_check","vercel_create_deployment","vercel_create_dns_record","vercel_create_edge_config","vercel_create_env_var","vercel_create_project","vercel_create_webhook","vercel_delete_alias","vercel_delete_deployment","vercel_delete_dns_record","vercel_delete_domain","vercel_delete_edge_config","vercel_delete_env_var","vercel_delete_project","vercel_delete_webhook","vercel_get_alias","vercel_get_check","vercel_get_deployment","vercel_get_deployment_events","vercel_get_domain","vercel_get_domain_config","vercel_get_edge_config","vercel_get_edge_config_items","vercel_get_env_vars","vercel_get_project","vercel_get_team","vercel_get_user","vercel_get_webhook","vercel_list_aliases","vercel_list_checks","vercel_list_deployment_files","vercel_list_deployments","vercel_list_dns_records","vercel_list_domains","vercel_list_edge_configs","vercel_list_project_domains","vercel_list_projects","vercel_list_team_members","vercel_list_teams","vercel_list_webhooks","vercel_pause_project","vercel_promote_deployment","vercel_remove_project_domain","vercel_rerequest_check","vercel_unpause_project","vercel_update_check","vercel_update_dns_record","vercel_update_edge_config_items","vercel_update_env_var","vercel_update_project","vercel_update_project_domain","vercel_verify_project_domain","video_falai","video_luma","video_minimax","video_runway","video_veo","vision_tool","vision_tool_v2","wealthbox_read_contact","wealthbox_read_note","wealthbox_read_task","wealthbox_write_contact","wealthbox_write_note","wealthbox_write_task","webflow_create_item","webflow_delete_item","webflow_get_item","webflow_list_items","webflow_update_item","webhook_request","whatsapp_get_media","whatsapp_mark_read","whatsapp_send_interactive","whatsapp_send_media","whatsapp_send_message","whatsapp_send_reaction","whatsapp_send_template","whatsapp_upload_media","wikipedia_content","wikipedia_random","wikipedia_search","wikipedia_summary","windchill_check_in_document","windchill_check_in_documents","windchill_check_out_document","windchill_check_out_documents","windchill_create_document","windchill_create_documents","windchill_delete_document","windchill_delete_documents","windchill_download_attachment","windchill_download_primary_content","windchill_get_document","windchill_get_document_structure","windchill_get_primary_content","windchill_get_valid_state_transitions","windchill_list_attachments","windchill_list_documents","windchill_revise_document","windchill_revise_documents","windchill_set_lifecycle_state","windchill_undo_check_out_document","windchill_undo_check_out_documents","windchill_update_common_properties","windchill_update_document","windchill_update_document_security_labels","windchill_update_documents","windchill_upload_attachments","windchill_upload_primary_content","wiza_company_enrichment","wiza_get_credits","wiza_individual_reveal","wiza_prospect_search","wordpress_create_category","wordpress_create_comment","wordpress_create_page","wordpress_create_post","wordpress_create_tag","wordpress_delete_category","wordpress_delete_comment","wordpress_delete_media","wordpress_delete_page","wordpress_delete_post","wordpress_delete_tag","wordpress_get_category","wordpress_get_current_user","wordpress_get_media","wordpress_get_page","wordpress_get_post","wordpress_get_tag","wordpress_get_user","wordpress_list_categories","wordpress_list_comments","wordpress_list_media","wordpress_list_pages","wordpress_list_posts","wordpress_list_tags","wordpress_list_users","wordpress_search_content","wordpress_update_category","wordpress_update_comment","wordpress_update_page","wordpress_update_post","wordpress_update_tag","wordpress_upload_media","workday_assign_onboarding","workday_change_job","workday_create_prehire","workday_get_compensation","workday_get_organizations","workday_get_worker","workday_hire_employee","workday_list_workers","workday_terminate_worker","workday_update_worker","workflow_executor","x_create_bookmark","x_create_tweet","x_delete_bookmark","x_delete_tweet","x_get_blocking","x_get_bookmarks","x_get_followers","x_get_following","x_get_liked_tweets","x_get_liking_users","x_get_me","x_get_personalized_trends","x_get_quote_tweets","x_get_retweeted_by","x_get_trends_by_woeid","x_get_tweets_by_ids","x_get_usage","x_get_user_mentions","x_get_user_timeline","x_get_user_tweets","x_hide_reply","x_manage_block","x_manage_follow","x_manage_like","x_manage_mute","x_manage_retweet","x_read","x_search","x_search_tweets","x_search_users","x_user","x_write","youtube_channel_info","youtube_channel_playlists","youtube_channel_videos","youtube_comments","youtube_playlist_items","youtube_search","youtube_trending","youtube_video_categories","youtube_video_details","zendesk_autocomplete_organizations","zendesk_create_organization","zendesk_create_organizations_bulk","zendesk_create_ticket","zendesk_create_tickets_bulk","zendesk_create_user","zendesk_create_users_bulk","zendesk_delete_organization","zendesk_delete_ticket","zendesk_delete_user","zendesk_get_current_user","zendesk_get_organization","zendesk_get_organizations","zendesk_get_ticket","zendesk_get_tickets","zendesk_get_user","zendesk_get_users","zendesk_merge_tickets","zendesk_search","zendesk_search_count","zendesk_search_users","zendesk_update_organization","zendesk_update_ticket","zendesk_update_tickets_bulk","zendesk_update_user","zendesk_update_users_bulk","zep_add_messages","zep_add_user","zep_create_thread","zep_delete_thread","zep_get_context","zep_get_messages","zep_get_threads","zep_get_user","zep_get_user_threads","zerobounce_get_credits","zerobounce_verify_email","zoho_desk_add_comment","zoho_desk_get_attachment","zoho_desk_get_contact","zoho_desk_get_thread","zoho_desk_get_ticket","zoho_desk_list_comments","zoho_desk_list_organizations","zoho_desk_list_threads","zoho_desk_list_tickets","zoho_desk_update_ticket","zoom_create_meeting","zoom_delete_meeting","zoom_delete_recording","zoom_get_meeting","zoom_get_meeting_invitation","zoom_get_meeting_recordings","zoom_list_meetings","zoom_list_past_participants","zoom_list_recordings","zoom_update_meeting","zoominfo_enrich_companies","zoominfo_enrich_contacts","zoominfo_search_companies","zoominfo_search_contacts","zoominfo_search_intent","zoominfo_search_news"]' + '["a2a_cancel_task","a2a_get_agent_card","a2a_get_task","a2a_send_message","affinity_batch_update_entity_fields","affinity_batch_update_list_entry_fields","affinity_create_list","affinity_create_list_field_dropdown_option","affinity_create_merge","affinity_create_note","affinity_create_reminder","affinity_delete_list_field_dropdown_option","affinity_delete_note","affinity_get_company","affinity_get_current_user","affinity_get_entity_field_value","affinity_get_list","affinity_get_list_entry","affinity_get_list_entry_field","affinity_get_list_field_dropdown_option","affinity_get_merge","affinity_get_merge_task","affinity_get_note","affinity_get_opportunity","affinity_get_person","affinity_get_saved_view","affinity_get_transcript","affinity_get_user","affinity_list_calls","affinity_list_chat_messages","affinity_list_companies","affinity_list_coworker_connections","affinity_list_emails","affinity_list_entity_field_values","affinity_list_entity_list_entries","affinity_list_entity_lists","affinity_list_entity_notes","affinity_list_entity_relationships","affinity_list_field_dropdown_options","affinity_list_field_metadata","affinity_list_field_value_changes","affinity_list_investor_executive_connections","affinity_list_list_entries","affinity_list_list_entry_field_value_changes","affinity_list_list_entry_fields","affinity_list_list_field_dropdown_options","affinity_list_list_fields","affinity_list_lists","affinity_list_meetings","affinity_list_merge_tasks","affinity_list_merges","affinity_list_note_attached_companies","affinity_list_note_attached_opportunities","affinity_list_note_attached_persons","affinity_list_note_replies","affinity_list_notes","affinity_list_opportunities","affinity_list_persons","affinity_list_reminders","affinity_list_saved_view_entries","affinity_list_saved_views","affinity_list_transcript_fragments","affinity_list_transcripts","affinity_list_users","affinity_search_companies","affinity_search_files","affinity_search_list_entries","affinity_search_notes","affinity_search_persons","affinity_semantic_search","affinity_update_entity_field_value","affinity_update_list_entry_field","affinity_update_list_field_dropdown_option","affinity_update_note","agentmail_create_draft","agentmail_create_inbox","agentmail_delete_draft","agentmail_delete_inbox","agentmail_delete_thread","agentmail_forward_message","agentmail_get_draft","agentmail_get_inbox","agentmail_get_message","agentmail_get_thread","agentmail_list_drafts","agentmail_list_inboxes","agentmail_list_messages","agentmail_list_threads","agentmail_reply_message","agentmail_send_draft","agentmail_send_message","agentmail_update_draft","agentmail_update_inbox","agentmail_update_message","agentmail_update_thread","agentphone_create_call","agentphone_create_contact","agentphone_create_number","agentphone_delete_contact","agentphone_get_call","agentphone_get_call_transcript","agentphone_get_contact","agentphone_get_conversation","agentphone_get_conversation_messages","agentphone_get_number_messages","agentphone_get_usage","agentphone_get_usage_daily","agentphone_get_usage_monthly","agentphone_list_calls","agentphone_list_contacts","agentphone_list_conversations","agentphone_list_numbers","agentphone_react_to_message","agentphone_release_number","agentphone_send_message","agentphone_update_contact","agentphone_update_conversation","agiloft_async_status","agiloft_attach_file","agiloft_attachment_info","agiloft_create_record","agiloft_delete_record","agiloft_get_choice_line_id","agiloft_list_tables","agiloft_lock_record","agiloft_nlp_search","agiloft_read_record","agiloft_remove_attachment","agiloft_retrieve_attachment","agiloft_run_action_button","agiloft_saved_search","agiloft_search_records","agiloft_select_records","agiloft_update_record","agiloft_upsert_record","ahrefs_anchors","ahrefs_backlinks","ahrefs_backlinks_stats","ahrefs_batch_analysis","ahrefs_broken_backlinks","ahrefs_domain_rating","ahrefs_domain_rating_history","ahrefs_keyword_overview","ahrefs_keywords_history","ahrefs_metrics","ahrefs_metrics_history","ahrefs_organic_competitors","ahrefs_organic_keywords","ahrefs_paid_pages","ahrefs_rank_tracker_competitors_overview","ahrefs_rank_tracker_competitors_stats","ahrefs_rank_tracker_overview","ahrefs_rank_tracker_serp_overview","ahrefs_refdomains_history","ahrefs_referring_domains","ahrefs_related_terms","ahrefs_site_audit_page_explorer","ahrefs_top_pages","airtable_create_records","airtable_delete_records","airtable_get_base_schema","airtable_get_record","airtable_list_bases","airtable_list_records","airtable_list_tables","airtable_update_multiple_records","airtable_update_record","airtable_upsert_records","airweave_search","algolia_add_record","algolia_batch_operations","algolia_browse_records","algolia_clear_records","algolia_copy_move_index","algolia_delete_by_filter","algolia_delete_index","algolia_delete_record","algolia_get_record","algolia_get_records","algolia_get_settings","algolia_get_task_status","algolia_list_indices","algolia_partial_update_record","algolia_search","algolia_update_settings","amplitude_event_segmentation","amplitude_funnels","amplitude_get_active_users","amplitude_get_revenue","amplitude_group_identify","amplitude_identify_user","amplitude_list_events","amplitude_realtime_active_users","amplitude_retention","amplitude_send_event","amplitude_user_activity","amplitude_user_profile","amplitude_user_search","apify_get_dataset_items","apify_get_run","apify_run_actor_async","apify_run_actor_sync","apify_run_task","apollo_account_bulk_create","apollo_account_bulk_update","apollo_account_create","apollo_account_search","apollo_account_update","apollo_contact_bulk_create","apollo_contact_bulk_update","apollo_contact_create","apollo_contact_search","apollo_contact_update","apollo_email_accounts","apollo_opportunity_create","apollo_opportunity_get","apollo_opportunity_search","apollo_opportunity_update","apollo_organization_bulk_enrich","apollo_organization_enrich","apollo_organization_search","apollo_people_bulk_enrich","apollo_people_enrich","apollo_people_search","apollo_sequence_add_contacts","apollo_sequence_search","apollo_task_create","apollo_task_search","appconfig_create_application","appconfig_create_configuration_profile","appconfig_create_environment","appconfig_create_hosted_configuration_version","appconfig_delete_application","appconfig_delete_configuration_profile","appconfig_delete_environment","appconfig_delete_hosted_configuration_version","appconfig_get_application","appconfig_get_configuration","appconfig_get_configuration_profile","appconfig_get_deployment","appconfig_get_environment","appconfig_get_hosted_configuration_version","appconfig_list_applications","appconfig_list_configuration_profiles","appconfig_list_deployment_strategies","appconfig_list_deployments","appconfig_list_environments","appconfig_list_hosted_configuration_versions","appconfig_start_deployment","appconfig_stop_deployment","appconfig_update_application","appconfig_update_configuration_profile","appconfig_update_environment","arxiv_get_author_papers","arxiv_get_paper","arxiv_search","asana_add_comment","asana_add_followers","asana_create_project","asana_create_section","asana_create_subtask","asana_create_task","asana_delete_task","asana_get_project","asana_get_projects","asana_get_task","asana_list_sections","asana_list_workspaces","asana_search_tasks","asana_update_task","ashby_add_candidate_tag","ashby_anonymize_candidate","ashby_change_application_source","ashby_change_application_stage","ashby_create_application","ashby_create_candidate","ashby_create_note","ashby_delete_application","ashby_get_application","ashby_get_candidate","ashby_get_job","ashby_get_job_posting","ashby_get_offer","ashby_get_opening","ashby_list_application_feedback","ashby_list_application_history","ashby_list_applications","ashby_list_archive_reasons","ashby_list_candidate_tags","ashby_list_candidates","ashby_list_custom_fields","ashby_list_departments","ashby_list_interview_plans","ashby_list_interview_stages","ashby_list_interviews","ashby_list_job_postings","ashby_list_jobs","ashby_list_locations","ashby_list_notes","ashby_list_offers","ashby_list_openings","ashby_list_sources","ashby_list_users","ashby_remove_candidate_tag","ashby_search_candidates","ashby_search_jobs","ashby_search_openings","ashby_search_users","ashby_set_custom_field_value","ashby_set_custom_field_values","ashby_transfer_application","ashby_update_candidate","ashby_upload_candidate_file","ashby_upload_resume","athena_batch_get_query_execution","athena_create_named_query","athena_delete_named_query","athena_get_named_query","athena_get_query_execution","athena_get_query_results","athena_list_databases","athena_list_named_queries","athena_list_query_executions","athena_list_table_metadata","athena_start_query","athena_stop_query","attio_assert_record","attio_create_attribute","attio_create_comment","attio_create_list","attio_create_list_entry","attio_create_note","attio_create_object","attio_create_record","attio_create_task","attio_create_webhook","attio_delete_comment","attio_delete_list_entry","attio_delete_note","attio_delete_record","attio_delete_task","attio_delete_webhook","attio_get_attribute","attio_get_comment","attio_get_list","attio_get_list_entry","attio_get_member","attio_get_note","attio_get_object","attio_get_record","attio_get_task","attio_get_thread","attio_get_webhook","attio_list_attributes","attio_list_lists","attio_list_members","attio_list_notes","attio_list_objects","attio_list_records","attio_list_tasks","attio_list_threads","attio_list_webhooks","attio_query_list_entries","attio_search_records","attio_update_attribute","attio_update_list","attio_update_list_entry","attio_update_object","attio_update_record","attio_update_task","attio_update_webhook","azure_data_explorer_create_table","azure_data_explorer_drop_table","azure_data_explorer_ingest_from_query","azure_data_explorer_ingest_inline","azure_data_explorer_list_databases","azure_data_explorer_list_functions","azure_data_explorer_list_tables","azure_data_explorer_management","azure_data_explorer_query","azure_data_explorer_show_database_schema","azure_data_explorer_show_ingestion_failures","azure_data_explorer_show_operations","azure_data_explorer_show_table_details","azure_data_explorer_show_table_schema","azure_devops_add_comment","azure_devops_create_work_item","azure_devops_get_build_log","azure_devops_get_build_timeline","azure_devops_get_comments","azure_devops_get_pipeline","azure_devops_get_pipeline_run","azure_devops_get_work_item","azure_devops_get_work_items_batch","azure_devops_get_work_items_between_builds","azure_devops_list_build_logs","azure_devops_list_builds","azure_devops_list_pipeline_runs","azure_devops_list_pipelines","azure_devops_query_work_items","azure_devops_update_work_item","bitbucket_approve_pull_request","bitbucket_create_branch","bitbucket_create_pull_request","bitbucket_create_pull_request_comment","bitbucket_decline_pull_request","bitbucket_delete_branch","bitbucket_get_commit","bitbucket_get_file","bitbucket_get_file_metadata","bitbucket_get_pipeline","bitbucket_get_pipeline_step_log","bitbucket_get_pull_request","bitbucket_get_pull_request_diff","bitbucket_get_pull_request_diffstat","bitbucket_get_pull_request_merge_task_status","bitbucket_get_repository","bitbucket_list_branches","bitbucket_list_commits","bitbucket_list_directory","bitbucket_list_pipeline_steps","bitbucket_list_pipelines","bitbucket_list_pull_request_comments","bitbucket_list_pull_request_commit_statuses","bitbucket_list_pull_requests","bitbucket_list_repositories","bitbucket_list_workspaces","bitbucket_merge_pull_request","bitbucket_request_pull_request_changes","bitbucket_stop_pipeline","bitbucket_trigger_pipeline","box_copy_file","box_create_folder","box_delete_file","box_delete_folder","box_download_file","box_get_file_info","box_list_folder_items","box_search","box_sign_cancel_request","box_sign_create_request","box_sign_get_request","box_sign_list_requests","box_sign_resend_request","box_update_file","box_upload_file","brandfetch_get_brand","brandfetch_search","brex_archive_budget","brex_create_budget","brex_create_spend_limit","brex_create_transfer","brex_create_vendor","brex_get_budget","brex_get_cash_account","brex_get_company","brex_get_current_user","brex_get_expense","brex_get_spend_limit","brex_get_transfer","brex_get_user","brex_get_vendor","brex_list_budgets","brex_list_card_accounts","brex_list_card_statements","brex_list_card_transactions","brex_list_cards","brex_list_cash_accounts","brex_list_cash_statements","brex_list_cash_transactions","brex_list_departments","brex_list_expenses","brex_list_locations","brex_list_spend_limits","brex_list_titles","brex_list_transfers","brex_list_users","brex_list_vendors","brex_match_receipt","brex_update_expense","brex_update_vendor","brex_upload_receipt","brightdata_cancel_snapshot","brightdata_discover","brightdata_download_snapshot","brightdata_scrape_dataset","brightdata_scrape_url","brightdata_serp_search","brightdata_snapshot_status","brightdata_sync_scrape","browser_use_run_task","buffer_create_idea","buffer_create_post","buffer_delete_post","buffer_edit_post","buffer_get_account","buffer_get_channels","buffer_get_idea_groups","buffer_get_ideas","buffer_get_post","buffer_get_posts","calcom_cancel_booking","calcom_confirm_booking","calcom_create_booking","calcom_create_event_type","calcom_create_schedule","calcom_decline_booking","calcom_delete_event_type","calcom_delete_schedule","calcom_get_booking","calcom_get_default_schedule","calcom_get_event_type","calcom_get_schedule","calcom_get_slots","calcom_list_bookings","calcom_list_event_types","calcom_list_schedules","calcom_reschedule_booking","calcom_update_event_type","calcom_update_schedule","calendly_cancel_event","calendly_create_event_invitee","calendly_create_invitee_no_show","calendly_create_scheduling_link","calendly_create_webhook","calendly_delete_invitee_no_show","calendly_delete_webhook","calendly_get_current_user","calendly_get_event_invitee","calendly_get_event_type","calendly_get_scheduled_event","calendly_get_user","calendly_list_event_invitees","calendly_list_event_type_available_times","calendly_list_event_types","calendly_list_organization_memberships","calendly_list_routing_form_submissions","calendly_list_routing_forms","calendly_list_scheduled_events","calendly_list_user_availability_schedules","calendly_list_user_busy_times","calendly_list_webhooks","cbinsights_chat","cbinsights_get_commercial_maturity_history","cbinsights_get_exit_probability_history","cbinsights_get_mosaic_history","cbinsights_get_org_business_relationships","cbinsights_get_org_funding_window","cbinsights_get_org_fundings","cbinsights_get_org_investments","cbinsights_get_org_management_and_board","cbinsights_get_org_outlook","cbinsights_get_org_portfolio_exits","cbinsights_get_org_revenue","cbinsights_get_scouting_report","cbinsights_get_strategy_map","cbinsights_list_business_relationships","cbinsights_list_funding_window","cbinsights_list_fundings","cbinsights_list_investments","cbinsights_list_management_and_board","cbinsights_list_outlook","cbinsights_list_portfolio_exits","cbinsights_list_revenue","cbinsights_lookup_organizations","cbinsights_rag","cbinsights_search_firmographics","circleback_add_tag_to_meetings","circleback_create_tag","circleback_delete_action_item","circleback_delete_meeting","circleback_delete_tag","circleback_get_company","circleback_get_meeting","circleback_get_person","circleback_get_transcript","circleback_list_action_items","circleback_list_calendar_events","circleback_list_companies","circleback_list_meetings","circleback_list_people","circleback_list_tags","circleback_remove_tag_from_meetings","circleback_search_meetings","circleback_update_action_item","circleback_update_meeting","circleback_update_tag","clay_populate","clerk_add_organization_member","clerk_ban_user","clerk_create_actor_token","clerk_create_allowlist_identifier","clerk_create_blocklist_identifier","clerk_create_organization","clerk_create_organization_invitation","clerk_create_user","clerk_delete_allowlist_identifier","clerk_delete_blocklist_identifier","clerk_delete_organization","clerk_delete_user","clerk_get_jwt_template","clerk_get_organization","clerk_get_session","clerk_get_user","clerk_get_user_oauth_token","clerk_list_allowlist_identifiers","clerk_list_blocklist_identifiers","clerk_list_jwt_templates","clerk_list_organization_invitations","clerk_list_organization_memberships","clerk_list_organizations","clerk_list_sessions","clerk_list_users","clerk_lock_user","clerk_remove_organization_member","clerk_revoke_actor_token","clerk_revoke_session","clerk_unban_user","clerk_unlock_user","clerk_update_organization","clerk_update_organization_membership","clerk_update_user","clickhouse_count_rows","clickhouse_create_database","clickhouse_create_table","clickhouse_delete","clickhouse_describe_table","clickhouse_drop_database","clickhouse_drop_partition","clickhouse_drop_table","clickhouse_execute","clickhouse_insert","clickhouse_insert_rows","clickhouse_introspect","clickhouse_kill_query","clickhouse_list_clusters","clickhouse_list_databases","clickhouse_list_mutations","clickhouse_list_partitions","clickhouse_list_running_queries","clickhouse_list_tables","clickhouse_optimize_table","clickhouse_query","clickhouse_rename_table","clickhouse_show_create_table","clickhouse_table_stats","clickhouse_truncate_table","clickhouse_update","clickup_add_tag_to_task","clickup_create_checklist","clickup_create_checklist_item","clickup_create_comment","clickup_create_folder","clickup_create_list","clickup_create_task","clickup_create_time_entry","clickup_delete_checklist","clickup_delete_checklist_item","clickup_delete_comment","clickup_delete_task","clickup_delete_time_entry","clickup_get_comments","clickup_get_custom_fields","clickup_get_folders","clickup_get_list_members","clickup_get_lists","clickup_get_running_timer","clickup_get_space_tags","clickup_get_spaces","clickup_get_task","clickup_get_task_members","clickup_get_tasks","clickup_get_time_entries","clickup_get_workspaces","clickup_remove_custom_field_value","clickup_remove_tag_from_task","clickup_search_tasks","clickup_set_custom_field_value","clickup_start_timer","clickup_stop_timer","clickup_update_checklist","clickup_update_checklist_item","clickup_update_comment","clickup_update_task","clickup_update_time_entry","clickup_upload_attachment","cloudflare_create_access_application","cloudflare_create_access_policy","cloudflare_create_access_service_token","cloudflare_create_dns_record","cloudflare_create_r2_bucket","cloudflare_create_rate_limit_rule","cloudflare_create_ruleset","cloudflare_create_ruleset_rule","cloudflare_create_zone","cloudflare_delete_access_application","cloudflare_delete_access_policy","cloudflare_delete_dns_record","cloudflare_delete_r2_bucket","cloudflare_delete_ruleset_rule","cloudflare_delete_zone","cloudflare_dns_analytics","cloudflare_get_access_application","cloudflare_get_r2_bucket","cloudflare_get_ruleset","cloudflare_get_ruleset_entrypoint","cloudflare_get_tunnel","cloudflare_get_tunnel_configuration","cloudflare_get_worker_script_settings","cloudflare_get_zone","cloudflare_get_zone_settings","cloudflare_list_access_applications","cloudflare_list_access_groups","cloudflare_list_access_identity_providers","cloudflare_list_access_policies","cloudflare_list_access_service_tokens","cloudflare_list_certificates","cloudflare_list_dns_records","cloudflare_list_managed_ruleset_overrides","cloudflare_list_r2_buckets","cloudflare_list_rate_limit_rules","cloudflare_list_rulesets","cloudflare_list_tunnels","cloudflare_list_worker_routes","cloudflare_list_worker_scripts","cloudflare_list_zones","cloudflare_purge_cache","cloudflare_revoke_access_service_token","cloudflare_update_access_application","cloudflare_update_access_policy","cloudflare_update_dns_record","cloudflare_update_rate_limit_rule","cloudflare_update_ruleset_rule","cloudflare_update_zone_setting","cloudformation_cancel_update_stack","cloudformation_create_change_set","cloudformation_create_stack","cloudformation_delete_stack","cloudformation_describe_change_set","cloudformation_describe_stack_drift_detection_status","cloudformation_describe_stack_events","cloudformation_describe_stacks","cloudformation_detect_stack_drift","cloudformation_execute_change_set","cloudformation_get_template","cloudformation_get_template_summary","cloudformation_list_stack_resources","cloudformation_update_stack","cloudformation_validate_template","cloudtrail_cancel_query","cloudtrail_describe_query","cloudtrail_describe_trails","cloudtrail_get_event_data_store","cloudtrail_get_event_selectors","cloudtrail_get_insight_selectors","cloudtrail_get_query_results","cloudtrail_get_trail","cloudtrail_get_trail_status","cloudtrail_list_event_data_stores","cloudtrail_list_tags","cloudtrail_list_trails","cloudtrail_lookup_events","cloudtrail_start_query","cloudwatch_describe_alarm_history","cloudwatch_describe_alarms","cloudwatch_describe_log_groups","cloudwatch_describe_log_streams","cloudwatch_filter_log_events","cloudwatch_get_log_events","cloudwatch_get_metric_statistics","cloudwatch_list_metrics","cloudwatch_mute_alarm","cloudwatch_put_log_group_retention","cloudwatch_put_metric_data","cloudwatch_query_logs","cloudwatch_unmute_alarm","codepipeline_disable_stage_transition","codepipeline_enable_stage_transition","codepipeline_get_pipeline","codepipeline_get_pipeline_execution","codepipeline_get_pipeline_state","codepipeline_list_action_executions","codepipeline_list_pipeline_executions","codepipeline_list_pipelines","codepipeline_put_approval_result","codepipeline_retry_stage_execution","codepipeline_start_execution","codepipeline_stop_execution","confluence_add_label","confluence_create_blogpost","confluence_create_comment","confluence_create_page","confluence_create_page_property","confluence_create_space","confluence_create_space_property","confluence_delete_attachment","confluence_delete_blogpost","confluence_delete_comment","confluence_delete_label","confluence_delete_page","confluence_delete_page_property","confluence_delete_space","confluence_delete_space_property","confluence_get_blogpost","confluence_get_page_ancestors","confluence_get_page_children","confluence_get_page_descendants","confluence_get_page_version","confluence_get_pages_by_label","confluence_get_space","confluence_get_task","confluence_get_user","confluence_list_attachments","confluence_list_blogposts","confluence_list_blogposts_in_space","confluence_list_comments","confluence_list_labels","confluence_list_page_properties","confluence_list_page_versions","confluence_list_pages_in_space","confluence_list_space_labels","confluence_list_space_permissions","confluence_list_space_properties","confluence_list_spaces","confluence_list_tasks","confluence_retrieve","confluence_search","confluence_search_in_space","confluence_update","confluence_update_blogpost","confluence_update_comment","confluence_update_space","confluence_update_task","confluence_upload_attachment","context_dev_classify_naics","context_dev_classify_sic","context_dev_crawl","context_dev_extract","context_dev_extract_product","context_dev_extract_products","context_dev_get_brand","context_dev_get_brand_by_email","context_dev_get_brand_by_name","context_dev_get_brand_by_ticker","context_dev_identify_transaction","context_dev_map","context_dev_scrape_fonts","context_dev_scrape_html","context_dev_scrape_images","context_dev_scrape_markdown","context_dev_scrape_styleguide","context_dev_screenshot","context_dev_search","convex_action","convex_document_deltas","convex_list_documents","convex_list_tables","convex_mutation","convex_query","convex_run_function","crowdstrike_create_indicators","crowdstrike_delete_indicators","crowdstrike_delete_rtr_session","crowdstrike_execute_rtr_command","crowdstrike_get_alert_details","crowdstrike_get_case_details","crowdstrike_get_host_group_details","crowdstrike_get_indicator_details","crowdstrike_get_rtr_command_status","crowdstrike_get_sensor_aggregates","crowdstrike_get_sensor_details","crowdstrike_get_vulnerability_details","crowdstrike_init_rtr_session","crowdstrike_perform_host_action","crowdstrike_perform_host_group_action","crowdstrike_query_alerts","crowdstrike_query_cases","crowdstrike_query_host_groups","crowdstrike_query_indicators","crowdstrike_query_sensors","crowdstrike_query_vulnerabilities","crowdstrike_update_alerts","crowdstrike_update_indicators","crunchbase_autocomplete","crunchbase_get_acquisition","crunchbase_get_entity","crunchbase_get_entity_card","crunchbase_get_fields_metadata","crunchbase_get_funding_round","crunchbase_get_organization","crunchbase_get_person","crunchbase_list_deleted_entities","crunchbase_search_acquisitions","crunchbase_search_entities","crunchbase_search_funding_rounds","crunchbase_search_organizations","crunchbase_search_people","cursor_add_followup","cursor_add_followup_v2","cursor_delete_agent","cursor_delete_agent_v2","cursor_download_artifact","cursor_download_artifact_v2","cursor_get_agent","cursor_get_agent_v2","cursor_get_api_key_info","cursor_get_api_key_info_v2","cursor_get_conversation","cursor_get_conversation_v2","cursor_launch_agent","cursor_launch_agent_v2","cursor_list_agents","cursor_list_agents_v2","cursor_list_artifacts","cursor_list_artifacts_v2","cursor_list_models","cursor_list_models_v2","cursor_list_repositories","cursor_list_repositories_v2","cursor_stop_agent","cursor_stop_agent_v2","dagster_delete_run","dagster_get_asset","dagster_get_run","dagster_get_run_logs","dagster_launch_run","dagster_list_assets","dagster_list_jobs","dagster_list_runs","dagster_list_schedules","dagster_list_sensors","dagster_materialize_assets","dagster_reexecute_run","dagster_report_asset_materialization","dagster_start_schedule","dagster_start_sensor","dagster_stop_schedule","dagster_stop_sensor","dagster_terminate_run","dagster_wipe_asset","databricks_cancel_run","databricks_execute_sql","databricks_get_cluster","databricks_get_job","databricks_get_run","databricks_get_run_output","databricks_get_statement","databricks_list_clusters","databricks_list_jobs","databricks_list_runs","databricks_list_warehouses","databricks_run_job","datadog_add_incident_todo","datadog_cancel_downtime","datadog_create_dashboard","datadog_create_downtime","datadog_create_event","datadog_create_incident","datadog_create_monitor","datadog_create_slo","datadog_delete_dashboard","datadog_delete_slo","datadog_get_browser_synthetics_results","datadog_get_dashboard","datadog_get_incident","datadog_get_monitor","datadog_get_security_signal","datadog_get_slo","datadog_get_slo_history","datadog_get_synthetics_results","datadog_get_synthetics_test","datadog_list_dashboards","datadog_list_downtimes","datadog_list_incidents","datadog_list_monitors","datadog_list_security_rules","datadog_list_security_signals","datadog_list_services","datadog_list_slos","datadog_list_synthetics_tests","datadog_mute_monitor","datadog_query_logs","datadog_query_timeseries","datadog_search_spans","datadog_send_logs","datadog_submit_metrics","datadog_trigger_synthetics_tests","datadog_unmute_monitor","datadog_update_incident","datadog_update_security_signal_assignee","datadog_update_security_signal_state","datadog_update_slo","datadog_update_synthetics_status","datagma_enrich_company","datagma_enrich_person","datagma_find_email","datagma_find_phone","datagma_get_credits","daytona_create_sandbox","daytona_delete_sandbox","daytona_download_file","daytona_execute_command","daytona_get_sandbox","daytona_git_clone","daytona_list_files","daytona_list_sandboxes","daytona_run_code","daytona_start_sandbox","daytona_stop_sandbox","daytona_upload_file","deployed_block_executor","deployments_deploy","deployments_get_version","deployments_list_versions","deployments_promote","deployments_undeploy","devin_append_session_tags","devin_archive_session","devin_create_session","devin_get_session","devin_get_session_tags","devin_list_session_attachments","devin_list_session_messages","devin_list_sessions","devin_replace_session_tags","devin_send_message","devin_terminate_session","discord_add_reaction","discord_archive_thread","discord_assign_role","discord_ban_member","discord_bulk_delete_messages","discord_create_channel","discord_create_invite","discord_create_role","discord_create_thread","discord_create_webhook","discord_delete_channel","discord_delete_invite","discord_delete_message","discord_delete_role","discord_delete_webhook","discord_edit_message","discord_execute_webhook","discord_get_channel","discord_get_invite","discord_get_member","discord_get_messages","discord_get_pinned_messages","discord_get_server","discord_get_user","discord_get_webhook","discord_join_thread","discord_kick_member","discord_leave_thread","discord_list_channels","discord_list_roles","discord_pin_message","discord_remove_reaction","discord_remove_role","discord_send_message","discord_unban_member","discord_unpin_message","discord_update_channel","discord_update_member","discord_update_role","docusign_create_from_template","docusign_download_document","docusign_get_envelope","docusign_list_envelopes","docusign_list_recipients","docusign_list_templates","docusign_send_envelope","docusign_void_envelope","downdetector_get_company","downdetector_get_company_attribution","downdetector_get_company_baseline","downdetector_get_company_events","downdetector_get_company_incidents","downdetector_get_company_indicators","downdetector_get_company_last_15","downdetector_get_company_status","downdetector_get_provider","downdetector_get_reports","downdetector_get_site_companies","downdetector_list_categories","downdetector_list_incidents","downdetector_list_sites","downdetector_search_companies","dropbox_copy","dropbox_create_folder","dropbox_create_shared_link","dropbox_delete","dropbox_download","dropbox_get_metadata","dropbox_list_folder","dropbox_list_revisions","dropbox_list_shared_links","dropbox_move","dropbox_restore","dropbox_search","dropbox_upload","dropcontact_enrich_contact","dspy_chain_of_thought","dspy_predict","dspy_react","dub_bulk_create_links","dub_bulk_delete_links","dub_bulk_update_links","dub_create_link","dub_create_tag","dub_delete_link","dub_get_analytics","dub_get_events","dub_get_link","dub_get_links_count","dub_get_qr_code","dub_list_domains","dub_list_folders","dub_list_links","dub_list_tags","dub_update_link","dub_upsert_link","duckduckgo_search","dynamodb_delete","dynamodb_get","dynamodb_introspect","dynamodb_put","dynamodb_query","dynamodb_scan","dynamodb_update","dynatrace_add_problem_comment","dynatrace_add_tags","dynatrace_close_problem","dynatrace_create_settings_object","dynatrace_create_slo","dynatrace_delete_problem_comment","dynatrace_delete_settings_object","dynatrace_delete_slo","dynatrace_delete_tag","dynatrace_execute_synthetic_monitors","dynatrace_get_attack","dynatrace_get_audit_logs","dynatrace_get_entity","dynatrace_get_event","dynatrace_get_metric","dynatrace_get_problem","dynatrace_get_problem_comment","dynatrace_get_security_problem","dynatrace_get_settings_object","dynatrace_get_slo","dynatrace_get_synthetic_batch","dynatrace_ingest_event","dynatrace_ingest_logs","dynatrace_ingest_metrics","dynatrace_list_attacks","dynatrace_list_entities","dynatrace_list_entity_types","dynatrace_list_events","dynatrace_list_metrics","dynatrace_list_problem_comments","dynatrace_list_problems","dynatrace_list_remediation_items","dynatrace_list_security_problems","dynatrace_list_settings_objects","dynatrace_list_settings_schemas","dynatrace_list_slos","dynatrace_list_synthetic_monitors","dynatrace_list_tags","dynatrace_mute_security_problem","dynatrace_mute_security_problems","dynatrace_query_metrics","dynatrace_search_logs","dynatrace_unmute_security_problem","dynatrace_unmute_security_problems","dynatrace_update_problem_comment","dynatrace_update_settings_object","dynatrace_update_slo","elasticsearch_bulk","elasticsearch_cluster_health","elasticsearch_cluster_stats","elasticsearch_count","elasticsearch_create_index","elasticsearch_delete_document","elasticsearch_delete_index","elasticsearch_get_document","elasticsearch_get_index","elasticsearch_index_document","elasticsearch_list_indices","elasticsearch_search","elasticsearch_update_document","elevenlabs_audio_isolation","elevenlabs_edit_voice_settings","elevenlabs_get_user","elevenlabs_get_voice","elevenlabs_get_voice_settings","elevenlabs_list_models","elevenlabs_list_voices","elevenlabs_sound_effects","elevenlabs_speech_to_speech","elevenlabs_tts","emailbison_attach_leads_to_campaign","emailbison_attach_tags_to_leads","emailbison_create_campaign","emailbison_create_lead","emailbison_create_tag","emailbison_get_lead","emailbison_list_campaigns","emailbison_list_leads","emailbison_list_replies","emailbison_list_tags","emailbison_update_campaign","emailbison_update_campaign_status","emailbison_update_lead","embeddings_cohere","embeddings_gemini","embeddings_mistral","embeddings_ollama","embeddings_openai","embeddings_openrouter","enrich_check_credits","enrich_company_funding","enrich_company_lookup","enrich_company_revenue","enrich_disposable_email_check","enrich_email_to_ip","enrich_email_to_person_lite","enrich_email_to_phone","enrich_email_to_profile","enrich_find_email","enrich_get_post_details","enrich_ip_to_company","enrich_linkedin_profile","enrich_linkedin_to_personal_email","enrich_linkedin_to_work_email","enrich_phone_finder","enrich_reverse_hash_lookup","enrich_sales_pointer_people","enrich_search_company","enrich_search_company_activities","enrich_search_company_employees","enrich_search_jobs","enrich_search_logo","enrich_search_people","enrich_search_people_activities","enrich_search_post_comments","enrich_search_post_comments_by_url","enrich_search_post_reactions","enrich_search_post_reactions_by_url","enrich_search_posts","enrich_search_similar_companies","enrich_verify_email","enrichment_run","enrow_find_email","enrow_verify_email","exa_agent","exa_answer","exa_find_similar_links","exa_get_contents","exa_search","extend_parser","extend_parser_v2","fathom_get_summary","fathom_get_transcript","fathom_list_meeting_types","fathom_list_meetings","fathom_list_team_members","fathom_list_teams","file_append","file_compress","file_create_folder","file_decompress","file_delete_folder","file_edit","file_fetch","file_get","file_get_content","file_list","file_manage_sharing","file_move","file_parser","file_parser_v2","file_parser_v3","file_read","file_restore_folder","file_search","file_update_folder","file_write","findymail_find_email_from_linkedin","findymail_find_email_from_name","findymail_find_emails_by_domain","findymail_find_employees","findymail_find_phone","findymail_get_company","findymail_get_credits","findymail_lookup_technologies","findymail_reverse_email_lookup","findymail_search_technologies","findymail_verify_email","firecrawl_agent","firecrawl_batch_scrape","firecrawl_batch_scrape_status","firecrawl_cancel_crawl","firecrawl_crawl","firecrawl_crawl_status","firecrawl_credit_usage","firecrawl_extract","firecrawl_extract_status","firecrawl_map","firecrawl_parse","firecrawl_scrape","firecrawl_search","fireflies_add_to_live_meeting","fireflies_create_bite","fireflies_delete_transcript","fireflies_get_transcript","fireflies_get_user","fireflies_list_bites","fireflies_list_contacts","fireflies_list_transcripts","fireflies_list_users","fireflies_upload_audio","flint_create_task","flint_generate_pages","flint_get_task","function_execute","gamma_check_status","gamma_generate","gamma_generate_from_template","gamma_list_folders","gamma_list_themes","github_add_assignees","github_add_assignees_v2","github_add_labels","github_add_labels_v2","github_cancel_workflow_run","github_cancel_workflow_run_v2","github_check_star","github_check_star_v2","github_close_issue","github_close_issue_v2","github_close_pr","github_close_pr_v2","github_comment","github_comment_v2","github_compare_commits","github_compare_commits_v2","github_create_branch","github_create_branch_v2","github_create_comment_reaction","github_create_comment_reaction_v2","github_create_file","github_create_file_v2","github_create_gist","github_create_gist_v2","github_create_issue","github_create_issue_reaction","github_create_issue_reaction_v2","github_create_issue_v2","github_create_milestone","github_create_milestone_v2","github_create_pr","github_create_pr_review","github_create_pr_review_v2","github_create_pr_v2","github_create_project","github_create_project_v2","github_create_release","github_create_release_v2","github_delete_branch","github_delete_branch_v2","github_delete_comment","github_delete_comment_reaction","github_delete_comment_reaction_v2","github_delete_comment_v2","github_delete_file","github_delete_file_v2","github_delete_gist","github_delete_gist_v2","github_delete_issue_reaction","github_delete_issue_reaction_v2","github_delete_milestone","github_delete_milestone_v2","github_delete_project","github_delete_project_v2","github_delete_release","github_delete_release_v2","github_fork_gist","github_fork_gist_v2","github_fork_repo","github_fork_repo_v2","github_get_branch","github_get_branch_protection","github_get_branch_protection_v2","github_get_branch_v2","github_get_commit","github_get_commit_v2","github_get_file_content","github_get_file_content_v2","github_get_gist","github_get_gist_v2","github_get_issue","github_get_issue_v2","github_get_latest_release","github_get_latest_release_v2","github_get_milestone","github_get_milestone_v2","github_get_pr_files","github_get_pr_files_v2","github_get_project","github_get_project_v2","github_get_readme","github_get_readme_v2","github_get_release","github_get_release_v2","github_get_tree","github_get_tree_v2","github_get_workflow","github_get_workflow_run","github_get_workflow_run_v2","github_get_workflow_v2","github_issue_comment","github_issue_comment_v2","github_job_logs","github_latest_commit","github_latest_commit_v2","github_list_branches","github_list_branches_v2","github_list_commits","github_list_commits_v2","github_list_forks","github_list_forks_v2","github_list_gists","github_list_gists_v2","github_list_issue_comments","github_list_issue_comments_v2","github_list_issues","github_list_issues_v2","github_list_milestones","github_list_milestones_v2","github_list_pr_comments","github_list_pr_comments_v2","github_list_projects","github_list_projects_v2","github_list_prs","github_list_prs_v2","github_list_releases","github_list_releases_v2","github_list_review_threads","github_list_stargazers","github_list_stargazers_v2","github_list_tags","github_list_tags_v2","github_list_workflow_runs","github_list_workflow_runs_v2","github_list_workflows","github_list_workflows_v2","github_merge_pr","github_merge_pr_v2","github_pr","github_pr_v2","github_remove_label","github_remove_label_v2","github_reply_review_thread","github_repo_info","github_repo_info_v2","github_request_reviewers","github_request_reviewers_v2","github_rerun_workflow","github_rerun_workflow_v2","github_resolve_review_thread","github_search_code","github_search_code_v2","github_search_commits","github_search_commits_v2","github_search_issues","github_search_issues_v2","github_search_repos","github_search_repos_v2","github_search_users","github_search_users_v2","github_star_gist","github_star_gist_v2","github_star_repo","github_star_repo_v2","github_status_check_rollup","github_trigger_workflow","github_trigger_workflow_v2","github_unstar_gist","github_unstar_gist_v2","github_unstar_repo","github_unstar_repo_v2","github_update_branch_protection","github_update_branch_protection_v2","github_update_comment","github_update_comment_v2","github_update_file","github_update_file_v2","github_update_gist","github_update_gist_v2","github_update_issue","github_update_issue_v2","github_update_milestone","github_update_milestone_v2","github_update_pr","github_update_pr_v2","github_update_project","github_update_project_v2","github_update_release","github_update_release_v2","gitlab_activate_user","gitlab_add_member","gitlab_add_saml_group_link","gitlab_approve_access_request","gitlab_approve_merge_request","gitlab_approve_user","gitlab_ban_user","gitlab_block_user","gitlab_cancel_pipeline","gitlab_compare_branches","gitlab_create_branch","gitlab_create_file","gitlab_create_issue","gitlab_create_issue_note","gitlab_create_merge_request","gitlab_create_merge_request_note","gitlab_create_pipeline","gitlab_create_release","gitlab_create_user","gitlab_deactivate_user","gitlab_delete_branch","gitlab_delete_issue","gitlab_delete_saml_group_link","gitlab_delete_user","gitlab_delete_user_identity","gitlab_deny_access_request","gitlab_get_file","gitlab_get_group","gitlab_get_issue","gitlab_get_job_log","gitlab_get_merge_request","gitlab_get_merge_request_changes","gitlab_get_pipeline","gitlab_get_project","gitlab_invite_member","gitlab_list_access_requests","gitlab_list_branches","gitlab_list_commits","gitlab_list_groups","gitlab_list_invitations","gitlab_list_issues","gitlab_list_members","gitlab_list_merge_requests","gitlab_list_pipeline_jobs","gitlab_list_pipelines","gitlab_list_projects","gitlab_list_releases","gitlab_list_repository_tree","gitlab_list_saml_group_links","gitlab_list_user_memberships","gitlab_merge_merge_request","gitlab_play_job","gitlab_reject_user","gitlab_remove_member","gitlab_retry_pipeline","gitlab_revoke_invitation","gitlab_search_users","gitlab_unban_user","gitlab_unblock_user","gitlab_update_file","gitlab_update_invitation","gitlab_update_issue","gitlab_update_member","gitlab_update_merge_request","gitlab_update_user","gmail_add_label","gmail_add_label_v2","gmail_archive","gmail_archive_v2","gmail_create_label_v2","gmail_delete","gmail_delete_draft_v2","gmail_delete_label_v2","gmail_delete_v2","gmail_draft","gmail_draft_v2","gmail_edit_draft_v2","gmail_get_draft_v2","gmail_get_thread_v2","gmail_list_drafts_v2","gmail_list_labels_v2","gmail_list_threads_v2","gmail_mark_read","gmail_mark_read_v2","gmail_mark_unread","gmail_mark_unread_v2","gmail_move","gmail_move_v2","gmail_read","gmail_read_v2","gmail_remove_label","gmail_remove_label_v2","gmail_search","gmail_search_v2","gmail_send","gmail_send_v2","gmail_trash_thread_v2","gmail_unarchive","gmail_unarchive_v2","gmail_untrash_thread_v2","gmail_update_label_v2","gong_aggregate_activity","gong_aggregate_by_period","gong_answered_scorecards","gong_ask_anything","gong_assign_flow_prospects","gong_create_call","gong_day_by_day_activity","gong_get_brief","gong_get_call","gong_get_call_transcript","gong_get_coaching","gong_get_extensive_calls","gong_get_folder_content","gong_get_logs","gong_get_prospect_flows","gong_get_user","gong_interaction_stats","gong_list_calls","gong_list_flows","gong_list_library_folders","gong_list_scorecards","gong_list_trackers","gong_list_users","gong_list_workspaces","gong_lookup_email","gong_lookup_phone","gong_purge_email_address","gong_purge_phone_number","gong_unassign_flow_prospects","google_ads_ad_performance","google_ads_campaign_performance","google_ads_list_ad_groups","google_ads_list_campaigns","google_ads_list_customers","google_ads_search","google_appsheet_add_rows","google_appsheet_delete_rows","google_appsheet_edit_rows","google_appsheet_find_rows","google_bigquery_create_dataset","google_bigquery_create_table","google_bigquery_delete_dataset","google_bigquery_delete_table","google_bigquery_get_query_results","google_bigquery_get_table","google_bigquery_insert_rows","google_bigquery_list_datasets","google_bigquery_list_table_data","google_bigquery_list_tables","google_bigquery_query","google_books_volume_details","google_books_volume_search","google_calendar_create","google_calendar_create_calendar","google_calendar_create_calendar_v2","google_calendar_create_v2","google_calendar_delete","google_calendar_delete_calendar","google_calendar_delete_calendar_v2","google_calendar_delete_v2","google_calendar_freebusy","google_calendar_freebusy_v2","google_calendar_get","google_calendar_get_v2","google_calendar_instances","google_calendar_instances_v2","google_calendar_invite","google_calendar_invite_v2","google_calendar_list","google_calendar_list_acl","google_calendar_list_acl_v2","google_calendar_list_calendars","google_calendar_list_calendars_v2","google_calendar_list_v2","google_calendar_move","google_calendar_move_v2","google_calendar_quick_add","google_calendar_quick_add_v2","google_calendar_share_calendar","google_calendar_share_calendar_v2","google_calendar_unshare_calendar","google_calendar_unshare_calendar_v2","google_calendar_update","google_calendar_update_acl","google_calendar_update_acl_v2","google_calendar_update_calendar","google_calendar_update_calendar_v2","google_calendar_update_v2","google_contacts_create","google_contacts_delete","google_contacts_get","google_contacts_list","google_contacts_search","google_contacts_update","google_docs_create","google_docs_create_named_range","google_docs_create_paragraph_bullets","google_docs_delete_content_range","google_docs_delete_named_range","google_docs_delete_paragraph_bullets","google_docs_insert_image","google_docs_insert_page_break","google_docs_insert_table","google_docs_insert_text","google_docs_read","google_docs_replace_text","google_docs_update_paragraph_style","google_docs_update_text_style","google_docs_write","google_drive_copy","google_drive_create_comment","google_drive_create_folder","google_drive_delete","google_drive_delete_comment","google_drive_download","google_drive_export","google_drive_get_about","google_drive_get_content","google_drive_get_file","google_drive_get_revision","google_drive_list","google_drive_list_comments","google_drive_list_permissions","google_drive_list_revisions","google_drive_move","google_drive_search","google_drive_share","google_drive_trash","google_drive_unshare","google_drive_untrash","google_drive_update","google_drive_upload","google_forms_batch_update","google_forms_create_form","google_forms_create_watch","google_forms_delete_watch","google_forms_get_form","google_forms_get_responses","google_forms_list_watches","google_forms_renew_watch","google_forms_set_publish_settings","google_groups_add_alias","google_groups_add_member","google_groups_create_group","google_groups_delete_group","google_groups_get_group","google_groups_get_member","google_groups_get_settings","google_groups_has_member","google_groups_list_aliases","google_groups_list_groups","google_groups_list_members","google_groups_remove_alias","google_groups_remove_member","google_groups_update_group","google_groups_update_member","google_groups_update_settings","google_maps_air_quality","google_maps_directions","google_maps_distance_matrix","google_maps_elevation","google_maps_geocode","google_maps_geolocate","google_maps_place_details","google_maps_places_nearby","google_maps_places_search","google_maps_pollen","google_maps_reverse_geocode","google_maps_snap_to_roads","google_maps_solar","google_maps_speed_limits","google_maps_timezone","google_maps_validate_address","google_meet_create_space","google_meet_end_conference","google_meet_get_conference_record","google_meet_get_space","google_meet_list_conference_records","google_meet_list_participants","google_pagespeed_analyze","google_search","google_sheets_append","google_sheets_append_v2","google_sheets_batch_clear_v2","google_sheets_batch_get_v2","google_sheets_batch_update_v2","google_sheets_clear_v2","google_sheets_copy_sheet_v2","google_sheets_create_spreadsheet_v2","google_sheets_delete_rows_v2","google_sheets_delete_sheet_v2","google_sheets_delete_spreadsheet_v2","google_sheets_get_spreadsheet_v2","google_sheets_read","google_sheets_read_v2","google_sheets_update","google_sheets_update_v2","google_sheets_write","google_sheets_write_v2","google_slides_add_image","google_slides_add_slide","google_slides_batch_update","google_slides_copy_presentation","google_slides_create","google_slides_create_line","google_slides_create_paragraph_bullets","google_slides_create_shape","google_slides_create_sheets_chart","google_slides_create_table","google_slides_create_video","google_slides_delete_object","google_slides_delete_paragraph_bullets","google_slides_delete_table_column","google_slides_delete_table_row","google_slides_delete_text","google_slides_duplicate_object","google_slides_export_presentation","google_slides_get_page","google_slides_get_thumbnail","google_slides_group_objects","google_slides_insert_table_columns","google_slides_insert_table_rows","google_slides_insert_text","google_slides_merge_table_cells","google_slides_read","google_slides_refresh_sheets_chart","google_slides_replace_all_shapes_with_image","google_slides_replace_all_shapes_with_sheets_chart","google_slides_replace_all_text","google_slides_replace_image","google_slides_reroute_line","google_slides_ungroup_objects","google_slides_unmerge_table_cells","google_slides_update_image_properties","google_slides_update_line_category","google_slides_update_line_properties","google_slides_update_page_element_alt_text","google_slides_update_page_element_transform","google_slides_update_page_elements_z_order","google_slides_update_page_properties","google_slides_update_paragraph_style","google_slides_update_shape_properties","google_slides_update_slide_properties","google_slides_update_slides_position","google_slides_update_table_border_properties","google_slides_update_table_cell_properties","google_slides_update_table_column_properties","google_slides_update_table_row_properties","google_slides_update_text_style","google_slides_update_video_properties","google_slides_write","google_tasks_create","google_tasks_delete","google_tasks_get","google_tasks_list","google_tasks_list_task_lists","google_tasks_update","google_translate_detect","google_translate_text","google_vault_add_held_accounts","google_vault_add_matters_permissions","google_vault_close_matters","google_vault_create_matters","google_vault_create_matters_export","google_vault_create_matters_holds","google_vault_create_saved_query","google_vault_delete_matters","google_vault_delete_matters_export","google_vault_delete_matters_holds","google_vault_delete_saved_query","google_vault_download_export_file","google_vault_list_matters","google_vault_list_matters_export","google_vault_list_matters_holds","google_vault_list_saved_queries","google_vault_remove_held_accounts","google_vault_remove_matters_permissions","google_vault_reopen_matters","google_vault_undelete_matters","google_vault_update_matters","google_vault_update_matters_holds","grafana_check_data_source_health","grafana_create_alert_rule","grafana_create_annotation","grafana_create_contact_point","grafana_create_dashboard","grafana_create_folder","grafana_delete_alert_rule","grafana_delete_annotation","grafana_delete_contact_point","grafana_delete_dashboard","grafana_delete_folder","grafana_get_alert_rule","grafana_get_alert_rule_group","grafana_get_dashboard","grafana_get_data_source","grafana_get_folder","grafana_get_health","grafana_list_alert_rules","grafana_list_annotations","grafana_list_contact_points","grafana_list_dashboards","grafana_list_data_sources","grafana_list_folders","grafana_move_folder","grafana_query_data_source","grafana_update_alert_rule","grafana_update_annotation","grafana_update_contact_point","grafana_update_dashboard","grafana_update_folder","grain_create_hook","grain_create_hook_v2","grain_delete_hook","grain_delete_hook_v2","grain_get_recording","grain_get_transcript","grain_list_hooks","grain_list_hooks_v2","grain_list_meeting_types","grain_list_recordings","grain_list_teams","grain_list_views","granola_create_webhook_endpoint","granola_delete_webhook_endpoint","granola_get_note","granola_get_transcript","granola_list_audit_events","granola_list_folders","granola_list_notes","granola_list_webhook_endpoints","granola_update_webhook_endpoint","greenhouse_get_application","greenhouse_get_candidate","greenhouse_get_job","greenhouse_get_user","greenhouse_list_applications","greenhouse_list_candidates","greenhouse_list_departments","greenhouse_list_job_stages","greenhouse_list_jobs","greenhouse_list_offices","greenhouse_list_users","greptile_index_repo","greptile_query","greptile_search","greptile_status","guardrails_validate","harmonic_batch_get_people","harmonic_clear_people_saved_search_net_new_results","harmonic_enrich_person","harmonic_get_company_employees","harmonic_get_email_enrichment_job","harmonic_get_email_enrichment_usage","harmonic_get_enrichment_status","harmonic_get_people_saved_search_net_new_results","harmonic_get_people_saved_search_results","harmonic_get_person","harmonic_list_people_saved_searches","harmonic_search_people_scout","harmonic_submit_email_enrichment_job","hex_cancel_run","hex_create_collection","hex_create_group","hex_deactivate_user","hex_delete_group","hex_get_collection","hex_get_data_connection","hex_get_group","hex_get_project","hex_get_project_runs","hex_get_queried_tables","hex_get_run_status","hex_list_collections","hex_list_data_connections","hex_list_groups","hex_list_projects","hex_list_users","hex_run_project","hex_update_collection","hex_update_group","hex_update_project","http_request","hubspot_add_list_memberships","hubspot_create_appointment","hubspot_create_association","hubspot_create_company","hubspot_create_contact","hubspot_create_deal","hubspot_create_email","hubspot_create_line_item","hubspot_create_list","hubspot_create_note","hubspot_create_ticket","hubspot_delete_association","hubspot_delete_company","hubspot_delete_contact","hubspot_delete_deal","hubspot_delete_line_item","hubspot_delete_ticket","hubspot_get_appointment","hubspot_get_association_labels","hubspot_get_cart","hubspot_get_company","hubspot_get_contact","hubspot_get_deal","hubspot_get_email","hubspot_get_line_item","hubspot_get_list","hubspot_get_list_memberships","hubspot_get_marketing_event","hubspot_get_note","hubspot_get_properties","hubspot_get_quote","hubspot_get_ticket","hubspot_get_users","hubspot_list_appointments","hubspot_list_associations","hubspot_list_carts","hubspot_list_companies","hubspot_list_contacts","hubspot_list_deals","hubspot_list_emails","hubspot_list_line_items","hubspot_list_lists","hubspot_list_marketing_events","hubspot_list_notes","hubspot_list_owners","hubspot_list_quotes","hubspot_list_tickets","hubspot_remove_list_memberships","hubspot_search_companies","hubspot_search_contacts","hubspot_search_deals","hubspot_search_emails","hubspot_search_line_items","hubspot_search_notes","hubspot_search_quotes","hubspot_search_tickets","hubspot_update_appointment","hubspot_update_company","hubspot_update_contact","hubspot_update_deal","hubspot_update_line_item","hubspot_update_ticket","huggingface_chat","hunter_companies_find","hunter_discover","hunter_domain_search","hunter_email_count","hunter_email_finder","hunter_email_verifier","iam_add_user_to_group","iam_attach_role_policy","iam_attach_user_policy","iam_create_access_key","iam_create_role","iam_create_user","iam_delete_access_key","iam_delete_role","iam_delete_user","iam_detach_role_policy","iam_detach_user_policy","iam_get_policy","iam_get_role","iam_get_user","iam_list_access_keys","iam_list_attached_role_policies","iam_list_attached_user_policies","iam_list_groups","iam_list_policies","iam_list_roles","iam_list_users","iam_remove_user_from_group","iam_simulate_principal_policy","iam_update_access_key","icypeas_find_email","icypeas_verify_email","identity_center_check_assignment_deletion_status","identity_center_check_assignment_status","identity_center_create_account_assignment","identity_center_delete_account_assignment","identity_center_describe_account","identity_center_describe_group","identity_center_describe_user","identity_center_get_group","identity_center_get_user","identity_center_list_account_assignments","identity_center_list_accounts","identity_center_list_assignments_for_account","identity_center_list_group_memberships","identity_center_list_groups","identity_center_list_instances","identity_center_list_permission_sets","image_generate","incidentio_actions_create","incidentio_actions_list","incidentio_actions_show","incidentio_actions_update","incidentio_alert_events_create","incidentio_alerts_list","incidentio_alerts_resolve","incidentio_alerts_show","incidentio_catalog_entries_list","incidentio_catalog_types_list","incidentio_custom_fields_create","incidentio_custom_fields_delete","incidentio_custom_fields_list","incidentio_custom_fields_show","incidentio_custom_fields_update","incidentio_escalation_paths_create","incidentio_escalation_paths_delete","incidentio_escalation_paths_list","incidentio_escalation_paths_show","incidentio_escalation_paths_update","incidentio_escalations_cancel","incidentio_escalations_create","incidentio_escalations_list","incidentio_escalations_show","incidentio_follow_ups_create","incidentio_follow_ups_list","incidentio_follow_ups_show","incidentio_follow_ups_update","incidentio_incident_alerts_list","incidentio_incident_memberships_create","incidentio_incident_memberships_revoke","incidentio_incident_participants_list","incidentio_incident_roles_create","incidentio_incident_roles_delete","incidentio_incident_roles_list","incidentio_incident_roles_show","incidentio_incident_roles_update","incidentio_incident_statuses_list","incidentio_incident_timestamps_list","incidentio_incident_timestamps_show","incidentio_incident_types_list","incidentio_incident_updates_list","incidentio_incidents_create","incidentio_incidents_list","incidentio_incidents_show","incidentio_incidents_update","incidentio_on_call_now","incidentio_schedule_entries_list","incidentio_schedule_overrides_create","incidentio_schedule_overrides_list","incidentio_schedules_create","incidentio_schedules_delete","incidentio_schedules_list","incidentio_schedules_show","incidentio_schedules_update","incidentio_severities_list","incidentio_teams_list","incidentio_teams_show","incidentio_users_list","incidentio_users_show","incidentio_workflows_create","incidentio_workflows_delete","incidentio_workflows_list","incidentio_workflows_show","incidentio_workflows_update","infisical_create_secret","infisical_delete_secret","infisical_get_secret","infisical_list_secrets","infisical_update_secret","instagram_delete_comment","instagram_download_media","instagram_get_account_insights","instagram_get_container_status","instagram_get_conversation_messages","instagram_get_media","instagram_get_media_insights","instagram_get_message","instagram_get_profile","instagram_get_publishing_limit","instagram_hide_comment","instagram_list_comments","instagram_list_conversations","instagram_list_media","instagram_list_stories","instagram_private_reply","instagram_publish_carousel","instagram_publish_image","instagram_publish_reel","instagram_publish_story","instagram_publish_video","instagram_reply_to_comment","instagram_send_text_message","instagram_set_comments_enabled","instantly_activate_campaign","instantly_create_campaign","instantly_create_lead","instantly_create_lead_list","instantly_delete_campaign","instantly_delete_leads","instantly_get_lead","instantly_list_campaigns","instantly_list_emails","instantly_list_lead_lists","instantly_list_leads","instantly_patch_campaign","instantly_patch_lead","instantly_pause_campaign","instantly_reply_to_email","instantly_update_lead_interest_status","intercom_assign_conversation_v2","intercom_attach_contact_to_company_v2","intercom_close_conversation_v2","intercom_create_company","intercom_create_company_v2","intercom_create_contact","intercom_create_contact_v2","intercom_create_event_v2","intercom_create_message","intercom_create_message_v2","intercom_create_note_v2","intercom_create_tag_v2","intercom_create_ticket","intercom_create_ticket_v2","intercom_delete_contact","intercom_delete_contact_v2","intercom_detach_contact_from_company_v2","intercom_get_company","intercom_get_company_v2","intercom_get_contact","intercom_get_contact_v2","intercom_get_conversation","intercom_get_conversation_v2","intercom_get_ticket","intercom_get_ticket_v2","intercom_list_admins_v2","intercom_list_companies","intercom_list_companies_v2","intercom_list_contacts","intercom_list_contacts_v2","intercom_list_conversations","intercom_list_conversations_v2","intercom_list_tags_v2","intercom_open_conversation_v2","intercom_reply_conversation","intercom_reply_conversation_v2","intercom_search_contacts","intercom_search_contacts_v2","intercom_search_conversations","intercom_search_conversations_v2","intercom_snooze_conversation_v2","intercom_tag_contact_v2","intercom_tag_conversation_v2","intercom_untag_contact_v2","intercom_update_contact","intercom_update_contact_v2","intercom_update_ticket_v2","jina_read_url","jina_search","jira_add_attachment","jira_add_comment","jira_add_watcher","jira_add_worklog","jira_assign_issue","jira_bulk_read","jira_create_issue_link","jira_delete_attachment","jira_delete_comment","jira_delete_issue","jira_delete_issue_link","jira_delete_worklog","jira_get_attachments","jira_get_comments","jira_get_fields","jira_get_project","jira_get_transitions","jira_get_users","jira_get_worklogs","jira_list_issue_types","jira_list_projects","jira_remove_watcher","jira_retrieve","jira_search_issues","jira_search_users","jira_transition_issue","jira_update","jira_update_comment","jira_update_worklog","jira_write","jotform_add_label_resources","jotform_clone_form","jotform_create_form","jotform_create_label","jotform_create_question","jotform_create_questions","jotform_create_report","jotform_create_submission","jotform_create_submissions","jotform_create_webhook","jotform_delete_form","jotform_delete_label","jotform_delete_question","jotform_delete_report","jotform_delete_submission","jotform_delete_webhook","jotform_get_form","jotform_get_form_properties","jotform_get_history","jotform_get_label","jotform_get_question","jotform_get_report","jotform_get_settings","jotform_get_submission","jotform_get_usage","jotform_get_user","jotform_list_form_files","jotform_list_form_reports","jotform_list_form_submissions","jotform_list_forms","jotform_list_label_resources","jotform_list_labels","jotform_list_questions","jotform_list_reports","jotform_list_submissions","jotform_list_subusers","jotform_list_webhooks","jotform_remove_label_resources","jotform_update_form_properties","jotform_update_label","jotform_update_question","jotform_update_settings","jotform_update_submission","jsm_add_comment","jsm_add_customer","jsm_add_organization","jsm_add_participants","jsm_answer_approval","jsm_attach_form","jsm_copy_forms","jsm_create_object","jsm_create_organization","jsm_create_request","jsm_delete_form","jsm_delete_object","jsm_externalise_form","jsm_get_approvals","jsm_get_comments","jsm_get_customers","jsm_get_form","jsm_get_form_answers","jsm_get_form_structure","jsm_get_form_templates","jsm_get_issue_forms","jsm_get_object","jsm_get_object_schema","jsm_get_object_type_attributes","jsm_get_organizations","jsm_get_participants","jsm_get_queues","jsm_get_request","jsm_get_request_type_fields","jsm_get_request_types","jsm_get_requests","jsm_get_service_desks","jsm_get_sla","jsm_get_transitions","jsm_internalise_form","jsm_list_object_schemas","jsm_list_object_types","jsm_reopen_form","jsm_save_form_answers","jsm_search_objects_aql","jsm_submit_form","jsm_transition_request","jsm_update_object","jupyter_copy_content","jupyter_create_file","jupyter_create_session","jupyter_delete_content","jupyter_delete_session","jupyter_get_content","jupyter_interrupt_kernel","jupyter_list_contents","jupyter_list_kernels","jupyter_list_kernelspecs","jupyter_list_sessions","jupyter_rename_content","jupyter_restart_kernel","jupyter_start_kernel","jupyter_stop_kernel","jupyter_upload_file","kalshi_amend_order","kalshi_amend_order_v2","kalshi_cancel_order","kalshi_cancel_order_v2","kalshi_create_order","kalshi_create_order_v2","kalshi_get_balance","kalshi_get_balance_v2","kalshi_get_candlesticks","kalshi_get_candlesticks_v2","kalshi_get_event","kalshi_get_event_candlesticks","kalshi_get_event_candlesticks_v2","kalshi_get_event_v2","kalshi_get_events","kalshi_get_events_v2","kalshi_get_exchange_announcements","kalshi_get_exchange_announcements_v2","kalshi_get_exchange_schedule","kalshi_get_exchange_schedule_v2","kalshi_get_exchange_status","kalshi_get_exchange_status_v2","kalshi_get_fills","kalshi_get_fills_v2","kalshi_get_market","kalshi_get_market_v2","kalshi_get_markets","kalshi_get_markets_v2","kalshi_get_order","kalshi_get_order_v2","kalshi_get_orderbook","kalshi_get_orderbook_v2","kalshi_get_orders","kalshi_get_orders_v2","kalshi_get_positions","kalshi_get_positions_v2","kalshi_get_series_by_ticker","kalshi_get_series_by_ticker_v2","kalshi_get_series_list","kalshi_get_series_list_v2","kalshi_get_settlements","kalshi_get_settlements_v2","kalshi_get_trades","kalshi_get_trades_v2","ketch_get_consent","ketch_get_subscriptions","ketch_invoke_right","ketch_set_consent","ketch_set_subscriptions","knowledge_create_document","knowledge_delete_chunk","knowledge_delete_document","knowledge_get_connector","knowledge_get_document","knowledge_list_chunks","knowledge_list_connectors","knowledge_list_documents","knowledge_list_tags","knowledge_search","knowledge_trigger_sync","knowledge_update_chunk","knowledge_upload_chunk","knowledge_upsert_document","lambda_add_permission","lambda_create_alias","lambda_create_event_source_mapping","lambda_create_function","lambda_create_function_url_config","lambda_delete_alias","lambda_delete_event_source_mapping","lambda_delete_function","lambda_delete_function_concurrency","lambda_delete_function_event_invoke_config","lambda_delete_function_url_config","lambda_delete_provisioned_concurrency_config","lambda_get_account_settings","lambda_get_alias","lambda_get_event_source_mapping","lambda_get_function","lambda_get_function_concurrency","lambda_get_function_configuration","lambda_get_function_event_invoke_config","lambda_get_function_recursion_config","lambda_get_function_url_config","lambda_get_layer_version","lambda_get_policy","lambda_get_provisioned_concurrency_config","lambda_get_runtime_management_config","lambda_invoke","lambda_list_aliases","lambda_list_event_source_mappings","lambda_list_function_event_invoke_configs","lambda_list_function_url_configs","lambda_list_functions","lambda_list_layer_versions","lambda_list_layers","lambda_list_provisioned_concurrency_configs","lambda_list_tags","lambda_list_versions_by_function","lambda_publish_version","lambda_put_function_concurrency","lambda_put_function_event_invoke_config","lambda_put_function_recursion_config","lambda_put_provisioned_concurrency_config","lambda_put_runtime_management_config","lambda_remove_permission","lambda_tag_resource","lambda_untag_resource","lambda_update_alias","lambda_update_event_source_mapping","lambda_update_function_code","lambda_update_function_configuration","lambda_update_function_url_config","langsmith_create_feedback","langsmith_create_run","langsmith_create_runs_batch","langsmith_get_run","langsmith_update_run","latex_compile","latex_get_package","latex_list_fonts","latex_search_packages","launchdarkly_create_flag","launchdarkly_delete_flag","launchdarkly_get_audit_log","launchdarkly_get_flag","launchdarkly_get_flag_status","launchdarkly_list_environments","launchdarkly_list_flags","launchdarkly_list_members","launchdarkly_list_projects","launchdarkly_list_segments","launchdarkly_toggle_flag","launchdarkly_update_flag","leadmagic_company_search","leadmagic_email_to_profile","leadmagic_find_email","leadmagic_find_mobile","leadmagic_get_credits","leadmagic_profile_search","leadmagic_profile_to_email","leadmagic_role_finder","leadmagic_validate_email","lemlist_get_activities","lemlist_get_lead","lemlist_send_email","linear_add_label_to_issue","linear_add_label_to_project","linear_archive_issue","linear_archive_label","linear_archive_project","linear_create_attachment","linear_create_comment","linear_create_customer","linear_create_customer_request","linear_create_customer_status","linear_create_customer_tier","linear_create_cycle","linear_create_favorite","linear_create_issue","linear_create_issue_relation","linear_create_label","linear_create_project","linear_create_project_label","linear_create_project_milestone","linear_create_project_status","linear_create_project_update","linear_create_workflow_state","linear_delete_attachment","linear_delete_comment","linear_delete_customer","linear_delete_customer_status","linear_delete_customer_tier","linear_delete_issue","linear_delete_issue_relation","linear_delete_project","linear_delete_project_label","linear_delete_project_milestone","linear_delete_project_status","linear_get_active_cycle","linear_get_customer","linear_get_cycle","linear_get_issue","linear_get_project","linear_get_viewer","linear_list_attachments","linear_list_comments","linear_list_customer_requests","linear_list_customer_statuses","linear_list_customer_tiers","linear_list_customers","linear_list_cycles","linear_list_favorites","linear_list_issue_relations","linear_list_labels","linear_list_notifications","linear_list_project_labels","linear_list_project_milestones","linear_list_project_statuses","linear_list_project_updates","linear_list_projects","linear_list_teams","linear_list_users","linear_list_workflow_states","linear_merge_customers","linear_read_issues","linear_remove_label_from_issue","linear_remove_label_from_project","linear_search_issues","linear_unarchive_issue","linear_update_attachment","linear_update_comment","linear_update_customer","linear_update_customer_request","linear_update_customer_status","linear_update_customer_tier","linear_update_issue","linear_update_label","linear_update_notification","linear_update_project","linear_update_project_label","linear_update_project_milestone","linear_update_project_status","linear_update_workflow_state","linkedin_get_profile","linkedin_share_post","linkup_search","linq_add_participant","linq_check_imessage","linq_check_rcs","linq_create_attachment","linq_create_chat","linq_create_contact_card","linq_create_webhook_subscription","linq_delete_attachment","linq_delete_message","linq_delete_webhook_subscription","linq_edit_message","linq_get_attachment","linq_get_chat","linq_get_contact_card","linq_get_message","linq_get_webhook_subscription","linq_leave_chat","linq_list_chats","linq_list_messages","linq_list_phone_numbers","linq_list_thread","linq_list_webhook_events","linq_list_webhook_subscriptions","linq_mark_chat_read","linq_react_to_message","linq_remove_participant","linq_send_message","linq_send_voice_memo","linq_share_contact_card","linq_start_typing","linq_stop_typing","linq_update_chat","linq_update_contact_card","linq_update_webhook_subscription","llm_chat","logfire_get_token_info","logfire_get_trace","logfire_query","logfire_search_records","logrocket_create_release","logrocket_get_audit_logs","logrocket_get_highlights","logrocket_identify_user","logrocket_list_exported_sessions","logrocket_request_highlights","logs_get","logs_get_execution","logs_get_run_details","logs_query","logs_query_runs","loops_check_contact_suppression","loops_create_contact","loops_create_contact_property","loops_delete_contact","loops_find_contact","loops_get_transactional_email","loops_list_contact_properties","loops_list_mailing_lists","loops_list_transactional_emails","loops_remove_contact_suppression","loops_send_event","loops_send_transactional_email","loops_update_contact","luma_add_guests","luma_cancel_event","luma_create_event","luma_get_event","luma_get_guest","luma_get_guests","luma_list_events","luma_lookup_event","luma_send_invites","luma_update_event","luma_update_guest_status","mailchimp_add_member","mailchimp_add_member_tags","mailchimp_add_or_update_member","mailchimp_add_segment_member","mailchimp_add_subscriber_to_automation","mailchimp_archive_member","mailchimp_create_audience","mailchimp_create_batch_operation","mailchimp_create_campaign","mailchimp_create_interest","mailchimp_create_interest_category","mailchimp_create_landing_page","mailchimp_create_merge_field","mailchimp_create_segment","mailchimp_create_template","mailchimp_delete_audience","mailchimp_delete_batch_operation","mailchimp_delete_campaign","mailchimp_delete_interest","mailchimp_delete_interest_category","mailchimp_delete_landing_page","mailchimp_delete_member","mailchimp_delete_merge_field","mailchimp_delete_segment","mailchimp_delete_template","mailchimp_get_audience","mailchimp_get_audiences","mailchimp_get_automation","mailchimp_get_automations","mailchimp_get_batch_operation","mailchimp_get_batch_operations","mailchimp_get_campaign","mailchimp_get_campaign_content","mailchimp_get_campaign_report","mailchimp_get_campaign_reports","mailchimp_get_campaigns","mailchimp_get_interest","mailchimp_get_interest_categories","mailchimp_get_interest_category","mailchimp_get_interests","mailchimp_get_landing_page","mailchimp_get_landing_pages","mailchimp_get_member","mailchimp_get_member_tags","mailchimp_get_members","mailchimp_get_merge_field","mailchimp_get_merge_fields","mailchimp_get_segment","mailchimp_get_segment_members","mailchimp_get_segments","mailchimp_get_template","mailchimp_get_templates","mailchimp_pause_automation","mailchimp_publish_landing_page","mailchimp_remove_member_tags","mailchimp_remove_segment_member","mailchimp_replicate_campaign","mailchimp_schedule_campaign","mailchimp_send_campaign","mailchimp_set_campaign_content","mailchimp_start_automation","mailchimp_unarchive_member","mailchimp_unpublish_landing_page","mailchimp_unschedule_campaign","mailchimp_update_audience","mailchimp_update_campaign","mailchimp_update_interest","mailchimp_update_interest_category","mailchimp_update_landing_page","mailchimp_update_member","mailchimp_update_merge_field","mailchimp_update_segment","mailchimp_update_template","mailgun_add_list_member","mailgun_create_mailing_list","mailgun_get_domain","mailgun_get_mailing_list","mailgun_get_message","mailgun_list_domains","mailgun_list_messages","mailgun_send_message","managed_agent_archive_session","managed_agent_create_session","managed_agent_delete_session","managed_agent_get_session","managed_agent_interrupt_session","managed_agent_list_events","managed_agent_respond_custom_tool","managed_agent_respond_tool_confirmation","managed_agent_run_session","managed_agent_send_message","managed_agent_update_session","manageengine_sdp_add_change_note","manageengine_sdp_add_problem_note","manageengine_sdp_add_request_note","manageengine_sdp_create_asset","manageengine_sdp_create_change","manageengine_sdp_create_problem","manageengine_sdp_create_request","manageengine_sdp_create_solution","manageengine_sdp_delete_asset","manageengine_sdp_delete_change","manageengine_sdp_delete_problem","manageengine_sdp_delete_request","manageengine_sdp_delete_solution","manageengine_sdp_get_asset","manageengine_sdp_get_change","manageengine_sdp_get_problem","manageengine_sdp_get_request","manageengine_sdp_get_solution","manageengine_sdp_list_assets","manageengine_sdp_list_change_notes","manageengine_sdp_list_changes","manageengine_sdp_list_problem_notes","manageengine_sdp_list_problems","manageengine_sdp_list_request_notes","manageengine_sdp_list_requests","manageengine_sdp_list_solutions","manageengine_sdp_update_asset","manageengine_sdp_update_change","manageengine_sdp_update_problem","manageengine_sdp_update_request","manageengine_sdp_update_solution","mem0_add_memories","mem0_get_memories","mem0_search_memories","memory_add","memory_delete","memory_get","memory_get_all","microsoft_ad_add_directory_role_member","microsoft_ad_add_group_member","microsoft_ad_add_user_app_role_assignment","microsoft_ad_assign_license","microsoft_ad_create_group","microsoft_ad_create_user","microsoft_ad_delete_group","microsoft_ad_delete_user","microsoft_ad_get_conditional_access_policy","microsoft_ad_get_device","microsoft_ad_get_group","microsoft_ad_get_user","microsoft_ad_list_authentication_methods","microsoft_ad_list_conditional_access_policies","microsoft_ad_list_devices","microsoft_ad_list_directory_audits","microsoft_ad_list_directory_role_members","microsoft_ad_list_directory_roles","microsoft_ad_list_group_members","microsoft_ad_list_groups","microsoft_ad_list_service_principal_app_role_assignments","microsoft_ad_list_service_principals","microsoft_ad_list_sign_ins","microsoft_ad_list_subscribed_skus","microsoft_ad_list_user_app_role_assignments","microsoft_ad_list_user_devices","microsoft_ad_list_user_licenses","microsoft_ad_list_users","microsoft_ad_remove_directory_role_member","microsoft_ad_remove_group_member","microsoft_ad_remove_user_app_role_assignment","microsoft_ad_reset_password","microsoft_ad_revoke_sign_in_sessions","microsoft_ad_set_password","microsoft_ad_update_group","microsoft_ad_update_user","microsoft_dataverse_associate","microsoft_dataverse_create_multiple","microsoft_dataverse_create_record","microsoft_dataverse_delete_record","microsoft_dataverse_disassociate","microsoft_dataverse_download_file","microsoft_dataverse_execute_action","microsoft_dataverse_execute_function","microsoft_dataverse_fetchxml_query","microsoft_dataverse_get_entity_metadata","microsoft_dataverse_get_record","microsoft_dataverse_list_records","microsoft_dataverse_search","microsoft_dataverse_update_multiple","microsoft_dataverse_update_record","microsoft_dataverse_upload_file","microsoft_dataverse_upsert_record","microsoft_dataverse_whoami","microsoft_dynamics_365_close_case","microsoft_dynamics_365_close_opportunity","microsoft_dynamics_365_create_record","microsoft_dynamics_365_get_record","microsoft_dynamics_365_list_records","microsoft_dynamics_365_qualify_lead","microsoft_dynamics_365_search_records","microsoft_dynamics_365_update_record","microsoft_excel_clear_range","microsoft_excel_create_table","microsoft_excel_delete_worksheet","microsoft_excel_format_range","microsoft_excel_read","microsoft_excel_read_v2","microsoft_excel_sort_range","microsoft_excel_table_add","microsoft_excel_worksheet_add","microsoft_excel_write","microsoft_excel_write_v2","microsoft_planner_create_bucket","microsoft_planner_create_plan","microsoft_planner_create_task","microsoft_planner_delete_bucket","microsoft_planner_delete_plan","microsoft_planner_delete_task","microsoft_planner_get_plan_details","microsoft_planner_get_task_details","microsoft_planner_list_buckets","microsoft_planner_list_plans","microsoft_planner_read_bucket","microsoft_planner_read_plan","microsoft_planner_read_task","microsoft_planner_update_bucket","microsoft_planner_update_plan","microsoft_planner_update_plan_details","microsoft_planner_update_task","microsoft_planner_update_task_details","microsoft_teams_delete_channel_message","microsoft_teams_delete_chat_message","microsoft_teams_get_message","microsoft_teams_list_channel_members","microsoft_teams_list_channels","microsoft_teams_list_chat_members","microsoft_teams_list_chats","microsoft_teams_list_team_members","microsoft_teams_list_teams","microsoft_teams_read_channel","microsoft_teams_read_chat","microsoft_teams_reply_to_message","microsoft_teams_set_reaction","microsoft_teams_unset_reaction","microsoft_teams_update_channel_message","microsoft_teams_update_chat_message","microsoft_teams_write_channel","microsoft_teams_write_chat","microsoft_word_append","microsoft_word_create","microsoft_word_create_from_template","microsoft_word_export_pdf","microsoft_word_list","microsoft_word_read","microsoft_word_replace_text","microsoft_word_update","millionverifier_get_credits","millionverifier_verify_email","mintlify_create_agent_job","mintlify_create_assistant_message","mintlify_detect_ai_prose","mintlify_get_agent_job","mintlify_get_assistant_caller_stats","mintlify_get_assistant_conversations","mintlify_get_feedback","mintlify_get_feedback_by_page","mintlify_get_page_content","mintlify_get_searches","mintlify_get_update_status","mintlify_get_views","mintlify_get_visitors","mintlify_search","mintlify_send_agent_message","mintlify_trigger_automation","mintlify_trigger_preview","mintlify_trigger_update","mistral_parser","mistral_parser_v2","mistral_parser_v3","modal_call_function","modal_chat_completion","modal_list_models","monday_archive_item","monday_change_column_value","monday_create_board","monday_create_column","monday_create_group","monday_create_item","monday_create_subitem","monday_create_update","monday_delete_item","monday_duplicate_item","monday_get_board","monday_get_groups","monday_get_item","monday_get_items","monday_list_boards","monday_move_item_to_group","monday_search_items","monday_update_item","mongodb_delete","mongodb_execute","mongodb_insert","mongodb_introspect","mongodb_query","mongodb_update","mssql_delete","mssql_execute","mssql_insert","mssql_introspect","mssql_query","mssql_update","mysql_delete","mysql_execute","mysql_insert","mysql_introspect","mysql_query","mysql_update","neo4j_create","neo4j_delete","neo4j_execute","neo4j_introspect","neo4j_merge","neo4j_query","neo4j_update","netsuite_attach_record","netsuite_batch_create_records","netsuite_batch_delete_records","netsuite_batch_get_records","netsuite_batch_update_records","netsuite_batch_upsert_records","netsuite_create_record","netsuite_delete_record","netsuite_detach_record","netsuite_execute_action","netsuite_execute_dataset","netsuite_execute_suiteql","netsuite_get_async_result","netsuite_get_async_status","netsuite_get_governance_limits","netsuite_get_record","netsuite_get_record_form","netsuite_get_record_metadata","netsuite_get_select_options","netsuite_get_server_time","netsuite_get_subresource","netsuite_list_datasets","netsuite_list_record_types","netsuite_list_records","netsuite_transform_record","netsuite_update_record","netsuite_upsert_record","neverbounce_get_credits","neverbounce_verify_email","new_relic_create_deployment_event","new_relic_get_entity","new_relic_nrql_query","new_relic_search_entities","notion_add_database_row","notion_add_database_row_v2","notion_append_blocks","notion_append_blocks_v2","notion_create_comment","notion_create_comment_v2","notion_create_database","notion_create_database_v2","notion_create_page","notion_create_page_v2","notion_delete_block","notion_delete_block_v2","notion_list_comments","notion_list_comments_v2","notion_list_users","notion_list_users_v2","notion_query_database","notion_query_database_v2","notion_read","notion_read_database","notion_read_database_v2","notion_read_v2","notion_retrieve_block","notion_retrieve_block_children","notion_retrieve_block_children_v2","notion_retrieve_block_v2","notion_retrieve_user","notion_retrieve_user_v2","notion_search","notion_search_v2","notion_update_block","notion_update_block_v2","notion_update_page","notion_update_page_v2","notion_write","notion_write_v2","obsidian_append_active","obsidian_append_note","obsidian_append_periodic_note","obsidian_create_note","obsidian_delete_note","obsidian_execute_command","obsidian_get_active","obsidian_get_note","obsidian_get_periodic_note","obsidian_list_commands","obsidian_list_files","obsidian_open_file","obsidian_patch_active","obsidian_patch_note","obsidian_search","oci_monitoring_create_alarm","oci_monitoring_create_alarm_suppression","oci_monitoring_delete_alarm","oci_monitoring_delete_alarm_suppression","oci_monitoring_get_alarm","oci_monitoring_get_alarm_history","oci_monitoring_get_alarm_suppression","oci_monitoring_list_alarm_suppressions","oci_monitoring_list_alarms","oci_monitoring_list_alarms_status","oci_monitoring_list_metrics","oci_monitoring_post_metric_data","oci_monitoring_remove_alarm_suppression","oci_monitoring_retrieve_dimension_states","oci_monitoring_summarize_alarm_suppression_history","oci_monitoring_summarize_metrics_data","oci_monitoring_update_alarm","okta_activate_group_rule","okta_activate_user","okta_add_user_to_group","okta_assign_group_to_app","okta_assign_user_role","okta_assign_user_to_app","okta_clear_user_sessions","okta_create_group","okta_create_group_rule","okta_create_user","okta_deactivate_group_rule","okta_deactivate_user","okta_delete_group","okta_delete_group_rule","okta_delete_user","okta_enroll_factor","okta_get_app","okta_get_factor","okta_get_group","okta_get_group_rule","okta_get_logs","okta_get_session","okta_get_user","okta_list_app_groups","okta_list_app_users","okta_list_apps","okta_list_factors","okta_list_group_members","okta_list_group_rules","okta_list_groups","okta_list_user_roles","okta_list_users","okta_remove_group_from_app","okta_remove_user_from_app","okta_remove_user_from_group","okta_remove_user_role","okta_reset_all_factors","okta_reset_factor","okta_reset_password","okta_revoke_session","okta_suspend_user","okta_unsuspend_user","okta_update_group","okta_update_user","onedrive_copy","onedrive_create_folder","onedrive_create_share_link","onedrive_delete","onedrive_download","onedrive_get_drive_info","onedrive_get_item","onedrive_list","onedrive_move","onedrive_search","onedrive_upload","onepassword_create_item","onepassword_delete_item","onepassword_get_item","onepassword_get_item_file","onepassword_get_vault","onepassword_list_items","onepassword_list_vaults","onepassword_replace_item","onepassword_resolve_secret","onepassword_update_item","openai_embeddings","openai_image","outlook_calendar_create_event","outlook_calendar_delete_event","outlook_calendar_get_event","outlook_calendar_list_events","outlook_calendar_respond","outlook_calendar_update_event","outlook_copy","outlook_create_folder","outlook_delete","outlook_draft","outlook_forward","outlook_get_attachment","outlook_list_attachments","outlook_list_folders","outlook_mark_read","outlook_mark_unread","outlook_move","outlook_read","outlook_reply","outlook_reply_all","outlook_search","outlook_send","outlook_update_message","pagerduty_add_note","pagerduty_create_incident","pagerduty_get_incident","pagerduty_get_service","pagerduty_list_escalation_policies","pagerduty_list_incident_alerts","pagerduty_list_incidents","pagerduty_list_oncalls","pagerduty_list_schedules","pagerduty_list_services","pagerduty_list_users","pagerduty_merge_incidents","pagerduty_send_event","pagerduty_snooze_incident","pagerduty_update_incident","parallel_deep_research","parallel_extract","parallel_search","pdl_autocomplete","pdl_bulk_company_enrich","pdl_bulk_person_enrich","pdl_clean_company","pdl_clean_location","pdl_clean_school","pdl_company_enrich","pdl_company_search","pdl_person_enrich","pdl_person_identify","pdl_person_search","perplexity_chat","perplexity_search","persona_approve_inquiry","persona_create_account","persona_create_inquiry","persona_create_report","persona_decline_inquiry","persona_expire_inquiry","persona_generate_inquiry_link","persona_get_account","persona_get_case","persona_get_document","persona_get_inquiry","persona_get_report","persona_get_verification","persona_import_accounts","persona_list_accounts","persona_list_cases","persona_list_inquiries","persona_list_inquiry_templates","persona_list_reports","persona_mark_inquiry_for_review","persona_print_inquiry_pdf","persona_redact_account","persona_redact_inquiry","persona_resume_inquiry","persona_update_account","persona_update_inquiry","pinecone_delete_vectors","pinecone_describe_index","pinecone_describe_index_stats","pinecone_fetch","pinecone_generate_embeddings","pinecone_list_indexes","pinecone_list_vector_ids","pinecone_search_text","pinecone_search_vector","pinecone_update_vector","pinecone_upsert_text","pipedrive_create_activity","pipedrive_create_deal","pipedrive_create_lead","pipedrive_create_project","pipedrive_delete_lead","pipedrive_get_activities","pipedrive_get_all_deals","pipedrive_get_deal","pipedrive_get_files","pipedrive_get_leads","pipedrive_get_mail_messages","pipedrive_get_mail_thread","pipedrive_get_pipeline_deals","pipedrive_get_pipelines","pipedrive_get_projects","pipedrive_update_activity","pipedrive_update_deal","pipedrive_update_lead","pitchbook_company_active_investors","pitchbook_company_bio","pitchbook_company_deal_service_providers","pitchbook_company_deals","pitchbook_company_financials","pitchbook_company_general_service_providers","pitchbook_company_industries","pitchbook_company_investors","pitchbook_company_most_recent_debt_financing","pitchbook_company_most_recent_financials","pitchbook_company_most_recent_financing","pitchbook_company_search","pitchbook_company_similar_companies","pitchbook_company_social_analytics","pitchbook_company_updates","pitchbook_company_vc_exit_predictions","pitchbook_contracts_history","pitchbook_cost_of_calls","pitchbook_credit_history","pitchbook_credit_news","pitchbook_credit_news_bulk","pitchbook_credit_news_most_recent","pitchbook_credit_news_search","pitchbook_deal_bio","pitchbook_deal_cap_table_history","pitchbook_deal_debt_lenders","pitchbook_deal_detailed","pitchbook_deal_investors","pitchbook_deal_multiples","pitchbook_deal_search","pitchbook_deal_service_providers","pitchbook_deal_stock_info","pitchbook_deal_tranche_info","pitchbook_deal_updates","pitchbook_deal_valuation","pitchbook_entity_affiliates","pitchbook_entity_locations","pitchbook_entity_news","pitchbook_entity_people","pitchbook_entity_updates","pitchbook_fund_active_investments","pitchbook_fund_benchmark","pitchbook_fund_bio","pitchbook_fund_cash_flows","pitchbook_fund_commitments","pitchbook_fund_investment_preferences","pitchbook_fund_investments","pitchbook_fund_performance","pitchbook_fund_search","pitchbook_fund_team","pitchbook_fund_updates","pitchbook_investor_active_investments","pitchbook_investor_bio","pitchbook_investor_board_seats","pitchbook_investor_deal_service_providers","pitchbook_investor_funds","pitchbook_investor_general_service_providers","pitchbook_investor_investments","pitchbook_investor_last_closed_fund","pitchbook_investor_preferences","pitchbook_investor_search","pitchbook_investor_updates","pitchbook_limited_partner_actual_allocations","pitchbook_limited_partner_bio","pitchbook_limited_partner_commitment_aggregates","pitchbook_limited_partner_commitment_preferences","pitchbook_limited_partner_commitments_detailed","pitchbook_limited_partner_search","pitchbook_limited_partner_service_providers","pitchbook_limited_partner_target_allocations","pitchbook_limited_partner_updates","pitchbook_lookup_table_structure","pitchbook_lookup_tables","pitchbook_patent_detailed","pitchbook_patent_search","pitchbook_people_search","pitchbook_person_bio","pitchbook_person_contact","pitchbook_person_education_work","pitchbook_sandbox_entities","pitchbook_search","pitchbook_service_provider_bio","pitchbook_service_provider_search","pitchbook_service_provider_updates","pitchbook_serviced_companies","pitchbook_serviced_deals","pitchbook_serviced_funds","pitchbook_serviced_investors","pitchbook_serviced_limited_partners","pitchbook_shared_search","pitchbook_usage_report","polymarket_get_activity","polymarket_get_event","polymarket_get_events","polymarket_get_holders","polymarket_get_last_trade_price","polymarket_get_leaderboard","polymarket_get_market","polymarket_get_markets","polymarket_get_midpoint","polymarket_get_orderbook","polymarket_get_positions","polymarket_get_price","polymarket_get_price_history","polymarket_get_series","polymarket_get_series_by_id","polymarket_get_spread","polymarket_get_tags","polymarket_get_tick_size","polymarket_get_trades","polymarket_search","postgresql_delete","postgresql_execute","postgresql_insert","postgresql_introspect","postgresql_query","postgresql_update","posthog_batch_events","posthog_capture_event","posthog_create_annotation","posthog_create_cohort","posthog_create_dashboard","posthog_create_experiment","posthog_create_feature_flag","posthog_create_insight","posthog_create_survey","posthog_delete_feature_flag","posthog_delete_person","posthog_delete_survey","posthog_evaluate_flags","posthog_get_cohort","posthog_get_dashboard","posthog_get_event_definition","posthog_get_experiment","posthog_get_feature_flag","posthog_get_insight","posthog_get_organization","posthog_get_person","posthog_get_project","posthog_get_property_definition","posthog_get_session_recording","posthog_get_survey","posthog_list_actions","posthog_list_annotations","posthog_list_cohorts","posthog_list_dashboards","posthog_list_event_definitions","posthog_list_experiments","posthog_list_feature_flags","posthog_list_insights","posthog_list_organizations","posthog_list_persons","posthog_list_projects","posthog_list_property_definitions","posthog_list_recording_playlists","posthog_list_session_recordings","posthog_list_surveys","posthog_query","posthog_update_cohort","posthog_update_event_definition","posthog_update_experiment","posthog_update_feature_flag","posthog_update_insight","posthog_update_property_definition","posthog_update_survey","profound_bot_logs","profound_bots_report","profound_category_assets","profound_category_personas","profound_category_prompts","profound_category_tags","profound_category_topics","profound_citation_prompts","profound_citations_report","profound_list_assets","profound_list_categories","profound_list_domains","profound_list_models","profound_list_optimizations","profound_list_personas","profound_list_regions","profound_optimization_analysis","profound_prompt_answers","profound_prompt_volume","profound_query_fanouts","profound_raw_logs","profound_referrals_report","profound_sentiment_report","profound_visibility_report","prospeo_account_information","prospeo_bulk_enrich_company","prospeo_bulk_enrich_person","prospeo_enrich_company","prospeo_enrich_person","prospeo_search_company","prospeo_search_person","prospeo_search_suggestions","pulse_parser","pulse_parser_v2","qdrant_fetch_points","qdrant_search_vector","qdrant_upsert_points","quartr_get_audio","quartr_get_company","quartr_get_event","quartr_get_event_summary","quartr_get_report","quartr_get_slide_deck","quartr_get_transcript","quartr_list_audio","quartr_list_companies","quartr_list_document_types","quartr_list_documents","quartr_list_event_types","quartr_list_events","quartr_list_live_events","quartr_list_reports","quartr_list_slide_decks","quartr_list_transcripts","quickbooks_add_attachment","quickbooks_create_bill","quickbooks_create_bill_payment","quickbooks_create_credit_memo","quickbooks_create_customer","quickbooks_create_customer_payment","quickbooks_create_deposit","quickbooks_create_employee","quickbooks_create_estimate","quickbooks_create_invoice","quickbooks_create_item","quickbooks_create_journal_entry","quickbooks_create_purchase","quickbooks_create_purchase_order","quickbooks_create_refund_receipt","quickbooks_create_sales_receipt","quickbooks_create_vendor","quickbooks_create_vendor_credit","quickbooks_download_attachment","quickbooks_download_transaction_pdf","quickbooks_email_transaction","quickbooks_get_company_info","quickbooks_read_accounting_transactions","quickbooks_read_attachments","quickbooks_read_master_data","quickbooks_read_purchasing_transactions","quickbooks_read_sales_transactions","quickbooks_run_financial_report","quickbooks_update_bill","quickbooks_update_bill_payment","quickbooks_update_credit_memo","quickbooks_update_customer","quickbooks_update_customer_payment","quickbooks_update_deposit","quickbooks_update_employee","quickbooks_update_estimate","quickbooks_update_invoice","quickbooks_update_item","quickbooks_update_journal_entry","quickbooks_update_purchase","quickbooks_update_purchase_order","quickbooks_update_refund_receipt","quickbooks_update_sales_receipt","quickbooks_update_vendor","quickbooks_update_vendor_credit","quickbooks_void_customer_payment","quickbooks_void_invoice","quiver_image_to_svg","quiver_list_models","quiver_text_to_svg","rabbitmq_create_binding","rabbitmq_create_exchange","rabbitmq_create_policy","rabbitmq_create_queue","rabbitmq_delete_binding","rabbitmq_delete_exchange","rabbitmq_delete_policy","rabbitmq_delete_queue","rabbitmq_get_exchange","rabbitmq_get_messages","rabbitmq_get_overview","rabbitmq_get_queue","rabbitmq_health_check","rabbitmq_list_bindings","rabbitmq_list_channels","rabbitmq_list_connections","rabbitmq_list_consumers","rabbitmq_list_exchange_bindings","rabbitmq_list_exchanges","rabbitmq_list_nodes","rabbitmq_list_policies","rabbitmq_list_queues","rabbitmq_list_vhosts","rabbitmq_publish_message","rabbitmq_purge_queue","railway_create_environment","railway_create_project","railway_create_service","railway_delete_environment","railway_delete_project","railway_delete_service","railway_delete_variable","railway_deploy_service","railway_get_deployment","railway_get_deployment_logs","railway_get_project","railway_list_deployments","railway_list_project_members","railway_list_projects","railway_list_variables","railway_restart_deployment","railway_rollback_deployment","railway_transfer_project","railway_update_project","railway_upsert_variable","rb2b_credit_check","rb2b_email_to_activity","rb2b_hem_to_best_linkedin","rb2b_hem_to_business_profile","rb2b_hem_to_linkedin","rb2b_hem_to_maid","rb2b_ip_to_company","rb2b_ip_to_hem","rb2b_ip_to_maid","rb2b_linkedin_slug_search","rb2b_linkedin_to_best_personal_email","rb2b_linkedin_to_business_profile","rb2b_linkedin_to_hashed_emails","rb2b_linkedin_to_mobile_phone","rb2b_linkedin_to_personal_email","rds_delete","rds_execute","rds_insert","rds_introspect","rds_query","rds_update","reddit_delete","reddit_edit","reddit_get_comments","reddit_get_controversial","reddit_get_info","reddit_get_me","reddit_get_messages","reddit_get_posts","reddit_get_saved","reddit_get_subreddit_info","reddit_get_subreddit_rules","reddit_get_user","reddit_get_user_comments","reddit_get_user_posts","reddit_hide","reddit_hot_posts","reddit_list_my_subreddits","reddit_lock","reddit_mark_all_read","reddit_mark_read","reddit_marknsfw","reddit_mod_approve","reddit_mod_distinguish","reddit_mod_remove","reddit_mod_sticky","reddit_reply","reddit_report","reddit_save","reddit_search","reddit_search_subreddits","reddit_send_message","reddit_submit_post","reddit_subscribe","reddit_unhide","reddit_unlock","reddit_unmarknsfw","reddit_unsave","reddit_vote","redis_command","redis_delete","redis_exists","redis_expire","redis_get","redis_hdel","redis_hget","redis_hgetall","redis_hset","redis_incr","redis_incrby","redis_keys","redis_llen","redis_lpop","redis_lpush","redis_lrange","redis_persist","redis_rpop","redis_rpush","redis_set","redis_setnx","redis_ttl","reducto_parser","reducto_parser_v2","resend_cancel_email","resend_create_audience","resend_create_broadcast","resend_create_contact","resend_delete_audience","resend_delete_contact","resend_get_audience","resend_get_broadcast","resend_get_contact","resend_get_email","resend_list_audiences","resend_list_contacts","resend_list_domains","resend_send","resend_send_broadcast","resend_update_contact","revenuecat_create_purchase","revenuecat_defer_google_subscription","revenuecat_delete_customer","revenuecat_get_customer","revenuecat_grant_entitlement","revenuecat_list_offerings","revenuecat_refund_google_subscription","revenuecat_revoke_entitlement","revenuecat_revoke_google_subscription","revenuecat_update_subscriber_attributes","rippling_bulk_create_custom_object_records","rippling_bulk_delete_custom_object_records","rippling_bulk_update_custom_object_records","rippling_create_business_partner","rippling_create_business_partner_group","rippling_create_custom_app","rippling_create_custom_object","rippling_create_custom_object_field","rippling_create_custom_object_record","rippling_create_custom_page","rippling_create_custom_setting","rippling_create_department","rippling_create_draft_hires","rippling_create_object_category","rippling_create_title","rippling_create_work_location","rippling_delete_business_partner","rippling_delete_business_partner_group","rippling_delete_custom_app","rippling_delete_custom_object","rippling_delete_custom_object_field","rippling_delete_custom_object_record","rippling_delete_custom_page","rippling_delete_custom_setting","rippling_delete_object_category","rippling_delete_title","rippling_delete_work_location","rippling_get_business_partner","rippling_get_business_partner_group","rippling_get_current_user","rippling_get_custom_app","rippling_get_custom_object","rippling_get_custom_object_field","rippling_get_custom_object_record","rippling_get_custom_object_record_by_external_id","rippling_get_custom_page","rippling_get_custom_setting","rippling_get_department","rippling_get_employment_type","rippling_get_job_function","rippling_get_object_category","rippling_get_report_run","rippling_get_supergroup","rippling_get_team","rippling_get_title","rippling_get_user","rippling_get_work_location","rippling_get_worker","rippling_list_business_partner_groups","rippling_list_business_partners","rippling_list_companies","rippling_list_custom_apps","rippling_list_custom_fields","rippling_list_custom_object_fields","rippling_list_custom_object_records","rippling_list_custom_objects","rippling_list_custom_pages","rippling_list_custom_settings","rippling_list_departments","rippling_list_employment_types","rippling_list_entitlements","rippling_list_job_functions","rippling_list_object_categories","rippling_list_supergroup_exclusion_members","rippling_list_supergroup_inclusion_members","rippling_list_supergroup_members","rippling_list_supergroups","rippling_list_teams","rippling_list_titles","rippling_list_users","rippling_list_work_locations","rippling_list_workers","rippling_query_custom_object_records","rippling_trigger_report_run","rippling_update_custom_app","rippling_update_custom_object","rippling_update_custom_object_field","rippling_update_custom_object_record","rippling_update_custom_page","rippling_update_custom_setting","rippling_update_department","rippling_update_object_category","rippling_update_supergroup_exclusion_members","rippling_update_supergroup_inclusion_members","rippling_update_title","rippling_update_work_location","rocketlane_add_field_option","rocketlane_add_project_members","rocketlane_add_task_assignees","rocketlane_add_task_dependencies","rocketlane_add_task_followers","rocketlane_archive_project","rocketlane_assign_placeholders","rocketlane_create_field","rocketlane_create_phase","rocketlane_create_project","rocketlane_create_space","rocketlane_create_space_document","rocketlane_create_task","rocketlane_create_time_entry","rocketlane_create_time_off","rocketlane_delete_field","rocketlane_delete_phase","rocketlane_delete_project","rocketlane_delete_space","rocketlane_delete_space_document","rocketlane_delete_task","rocketlane_delete_time_entry","rocketlane_delete_time_off","rocketlane_get_field","rocketlane_get_invoice","rocketlane_get_invoice_line_items","rocketlane_get_invoice_payments","rocketlane_get_phase","rocketlane_get_project","rocketlane_get_space","rocketlane_get_space_document","rocketlane_get_task","rocketlane_get_time_entry","rocketlane_get_time_off","rocketlane_get_user","rocketlane_import_template","rocketlane_list_fields","rocketlane_list_invoices","rocketlane_list_phases","rocketlane_list_placeholders","rocketlane_list_projects","rocketlane_list_resource_allocations","rocketlane_list_space_documents","rocketlane_list_spaces","rocketlane_list_tasks","rocketlane_list_time_entries","rocketlane_list_time_entry_categories","rocketlane_list_time_offs","rocketlane_list_users","rocketlane_move_task_to_phase","rocketlane_remove_project_members","rocketlane_remove_task_assignees","rocketlane_remove_task_dependencies","rocketlane_remove_task_followers","rocketlane_search_time_entries","rocketlane_unassign_placeholders","rocketlane_update_field","rocketlane_update_field_option","rocketlane_update_phase","rocketlane_update_project","rocketlane_update_space","rocketlane_update_space_document","rocketlane_update_task","rocketlane_update_time_entry","rootly_acknowledge_alert","rootly_add_incident_event","rootly_add_subscribers","rootly_assign_incident_role","rootly_create_action_item","rootly_create_alert","rootly_create_incident","rootly_create_status_page_event","rootly_delete_action_item","rootly_delete_incident","rootly_escalate_alert","rootly_get_alert","rootly_get_incident","rootly_list_action_items","rootly_list_alerts","rootly_list_causes","rootly_list_environments","rootly_list_escalation_policies","rootly_list_functionalities","rootly_list_incident_events","rootly_list_incident_roles","rootly_list_incident_types","rootly_list_incidents","rootly_list_on_calls","rootly_list_playbooks","rootly_list_retrospectives","rootly_list_schedules","rootly_list_services","rootly_list_severities","rootly_list_teams","rootly_list_users","rootly_mitigate_incident","rootly_remove_subscribers","rootly_resolve_alert","rootly_resolve_incident","rootly_run_workflow","rootly_snooze_alert","rootly_unassign_incident_role","rootly_update_action_item","rootly_update_alert","rootly_update_incident","s3_copy_object","s3_create_bucket","s3_delete_bucket","s3_delete_object","s3_delete_objects","s3_get_object","s3_head_object","s3_list_buckets","s3_list_objects","s3_presigned_url","s3_put_object","sailpoint_approve_access_request","sailpoint_cancel_access_request","sailpoint_decide_certification_review_items","sailpoint_get_access_profile","sailpoint_get_access_profile_entitlements","sailpoint_get_access_request_config","sailpoint_get_access_request_status","sailpoint_get_account","sailpoint_get_account_activity","sailpoint_get_account_entitlements","sailpoint_get_account_selections","sailpoint_get_campaign","sailpoint_get_certification","sailpoint_get_entitlement","sailpoint_get_entitlement_request_config","sailpoint_get_identity","sailpoint_get_role","sailpoint_get_role_entitlements","sailpoint_get_source","sailpoint_get_task_status","sailpoint_list_access_profiles","sailpoint_list_account_activities","sailpoint_list_accounts","sailpoint_list_campaigns","sailpoint_list_certification_review_items","sailpoint_list_certifications","sailpoint_list_entitlements","sailpoint_list_identities","sailpoint_list_identity_entitlements","sailpoint_list_pending_access_request_approvals","sailpoint_list_roles","sailpoint_list_sources","sailpoint_load_accounts","sailpoint_load_entitlements","sailpoint_reject_access_request","sailpoint_request_access","sailpoint_search","sailpoint_search_aggregate","sailpoint_search_count","sailpoint_sign_off_certification","salesforce_create_account","salesforce_create_case","salesforce_create_contact","salesforce_create_custom_field","salesforce_create_custom_object","salesforce_create_lead","salesforce_create_opportunity","salesforce_create_task","salesforce_delete_account","salesforce_delete_case","salesforce_delete_contact","salesforce_delete_custom_field","salesforce_delete_lead","salesforce_delete_opportunity","salesforce_delete_task","salesforce_describe_object","salesforce_get_accounts","salesforce_get_cases","salesforce_get_contacts","salesforce_get_dashboard","salesforce_get_leads","salesforce_get_opportunities","salesforce_get_report","salesforce_get_tasks","salesforce_list_dashboards","salesforce_list_objects","salesforce_list_report_types","salesforce_list_reports","salesforce_query","salesforce_query_more","salesforce_refresh_dashboard","salesforce_run_report","salesforce_tooling_query","salesforce_update_account","salesforce_update_case","salesforce_update_contact","salesforce_update_custom_field","salesforce_update_lead","salesforce_update_opportunity","salesforce_update_task","sap_concur_approve_expense_report","sap_concur_associate_attendees","sap_concur_create_cash_advance","sap_concur_create_expected_expense","sap_concur_create_expense_report","sap_concur_create_list_item","sap_concur_create_purchase_request","sap_concur_create_quick_expense","sap_concur_create_quick_expense_with_image","sap_concur_create_report_comment","sap_concur_create_travel_request","sap_concur_create_user","sap_concur_delete_expected_expense","sap_concur_delete_expense","sap_concur_delete_expense_report","sap_concur_delete_list_item","sap_concur_delete_travel_request","sap_concur_delete_user","sap_concur_get_allocation","sap_concur_get_budget","sap_concur_get_cash_advance","sap_concur_get_expected_expense","sap_concur_get_expense","sap_concur_get_expense_report","sap_concur_get_itemizations","sap_concur_get_itinerary","sap_concur_get_list","sap_concur_get_list_item","sap_concur_get_purchase_request","sap_concur_get_receipt","sap_concur_get_receipt_status","sap_concur_get_request_cash_advance","sap_concur_get_travel_profile","sap_concur_get_travel_request","sap_concur_get_user","sap_concur_issue_cash_advance","sap_concur_list_allocations","sap_concur_list_attendee_associations","sap_concur_list_budget_categories","sap_concur_list_budgets","sap_concur_list_exceptions","sap_concur_list_expected_expenses","sap_concur_list_expense_reports","sap_concur_list_expenses","sap_concur_list_itineraries","sap_concur_list_list_items","sap_concur_list_lists","sap_concur_list_receipts","sap_concur_list_report_comments","sap_concur_list_reports_to_approve","sap_concur_list_travel_profiles_summary","sap_concur_list_travel_request_comments","sap_concur_list_travel_requests","sap_concur_list_users","sap_concur_move_travel_request","sap_concur_recall_expense_report","sap_concur_remove_all_attendees","sap_concur_search_locations","sap_concur_search_users","sap_concur_send_back_expense_report","sap_concur_submit_expense_report","sap_concur_update_allocation","sap_concur_update_expected_expense","sap_concur_update_expense","sap_concur_update_expense_report","sap_concur_update_list_item","sap_concur_update_travel_request","sap_concur_update_user","sap_concur_upload_exchange_rates","sap_concur_upload_receipt_image","sap_s4hana_create_business_partner","sap_s4hana_create_purchase_order","sap_s4hana_create_purchase_requisition","sap_s4hana_create_sales_order","sap_s4hana_delete_sales_order","sap_s4hana_get_billing_document","sap_s4hana_get_business_partner","sap_s4hana_get_customer","sap_s4hana_get_inbound_delivery","sap_s4hana_get_material_document","sap_s4hana_get_outbound_delivery","sap_s4hana_get_product","sap_s4hana_get_purchase_order","sap_s4hana_get_purchase_requisition","sap_s4hana_get_sales_order","sap_s4hana_get_supplier","sap_s4hana_get_supplier_invoice","sap_s4hana_list_billing_documents","sap_s4hana_list_business_partners","sap_s4hana_list_customers","sap_s4hana_list_inbound_deliveries","sap_s4hana_list_material_documents","sap_s4hana_list_material_stock","sap_s4hana_list_outbound_deliveries","sap_s4hana_list_products","sap_s4hana_list_purchase_orders","sap_s4hana_list_purchase_requisitions","sap_s4hana_list_sales_orders","sap_s4hana_list_supplier_invoices","sap_s4hana_list_suppliers","sap_s4hana_odata_query","sap_s4hana_update_business_partner","sap_s4hana_update_customer","sap_s4hana_update_product","sap_s4hana_update_purchase_order","sap_s4hana_update_purchase_requisition","sap_s4hana_update_sales_order","sap_s4hana_update_supplier","search_tool","secrets_manager_create_secret","secrets_manager_delete_secret","secrets_manager_describe_secret","secrets_manager_get_secret","secrets_manager_list_secrets","secrets_manager_restore_secret","secrets_manager_rotate_secret","secrets_manager_tag_resource","secrets_manager_untag_resource","secrets_manager_update_secret","semrush_backlinks","semrush_backlinks_anchors","semrush_backlinks_competitors","semrush_backlinks_geo_distribution","semrush_backlinks_indexed_pages","semrush_backlinks_overview","semrush_backlinks_tld_distribution","semrush_batch_keyword_overview","semrush_broad_match_keywords","semrush_domain_ad_copies","semrush_domain_ad_history","semrush_domain_organic_competitors","semrush_domain_organic_keywords","semrush_domain_overview","semrush_domain_overview_all","semrush_domain_overview_history","semrush_domain_paid_competitors","semrush_domain_paid_keywords","semrush_domain_pla_copies","semrush_domain_pla_keywords","semrush_domain_vs_domain","semrush_keyword_ad_history","semrush_keyword_difficulty","semrush_keyword_overview","semrush_keyword_overview_all","semrush_keyword_questions","semrush_organic_results","semrush_paid_results","semrush_referring_domains","semrush_referring_ips","semrush_related_keywords","semrush_subdomain_ad_copies","semrush_subdomain_organic_keywords","semrush_subdomain_overview","semrush_subdomain_overview_all","semrush_subdomain_overview_history","semrush_subdomain_paid_keywords","semrush_top_domains","semrush_url_organic_keywords","semrush_url_overview","semrush_url_overview_all","semrush_url_overview_history","semrush_url_paid_keywords","semrush_winners_and_losers","sendblue_evaluate_service","sendblue_get_message","sendblue_send_group_message","sendblue_send_message","sendblue_send_typing_indicator","sendgrid_add_contact","sendgrid_add_contacts_to_list","sendgrid_create_list","sendgrid_create_template","sendgrid_create_template_version","sendgrid_delete_contacts","sendgrid_delete_list","sendgrid_delete_template","sendgrid_get_contact","sendgrid_get_list","sendgrid_get_template","sendgrid_list_all_lists","sendgrid_list_templates","sendgrid_remove_contacts_from_list","sendgrid_search_contacts","sendgrid_send_mail","sentry_events_get","sentry_events_list","sentry_issues_get","sentry_issues_list","sentry_issues_update","sentry_projects_create","sentry_projects_get","sentry_projects_list","sentry_projects_update","sentry_releases_create","sentry_releases_deploy","sentry_releases_list","sentry_teams_list","serper_search","servicenow_add_incident_comment","servicenow_aggregate","servicenow_close_incident","servicenow_create_change_request","servicenow_create_incident","servicenow_create_record","servicenow_delete_record","servicenow_download_attachment","servicenow_find_user","servicenow_get_change_next_states","servicenow_get_change_request","servicenow_get_ci","servicenow_get_incident","servicenow_get_knowledge_article","servicenow_get_requested_item","servicenow_list_approvals","servicenow_list_attachments","servicenow_list_catalog_items","servicenow_list_change_requests","servicenow_list_change_tasks","servicenow_list_ci_relationships","servicenow_list_group_members","servicenow_list_incidents","servicenow_list_requested_items","servicenow_order_catalog_item","servicenow_read_record","servicenow_resolve_incident","servicenow_search_cis","servicenow_search_knowledge","servicenow_update_approval","servicenow_update_change_request","servicenow_update_change_state","servicenow_update_incident","servicenow_update_record","servicenow_upload_attachment","ses_create_configuration_set","ses_create_email_identity","ses_create_template","ses_delete_email_identity","ses_delete_suppressed_destination","ses_delete_template","ses_get_account","ses_get_email_identity","ses_get_suppressed_destination","ses_get_template","ses_list_identities","ses_list_suppressed_destinations","ses_list_templates","ses_put_suppressed_destination","ses_send_bulk_email","ses_send_custom_verification_email","ses_send_email","ses_send_templated_email","ses_update_template","sftp_delete","sftp_download","sftp_list","sftp_mkdir","sftp_upload","sharepoint_add_list_items","sharepoint_create_list","sharepoint_create_page","sharepoint_delete_file","sharepoint_delete_list_item","sharepoint_delete_page","sharepoint_download_file","sharepoint_get_drive_item","sharepoint_get_list","sharepoint_get_list_item","sharepoint_list_sites","sharepoint_publish_page","sharepoint_read_page","sharepoint_update_list","sharepoint_update_page","sharepoint_upload_file","shopify_adjust_inventory","shopify_cancel_order","shopify_create_customer","shopify_create_fulfillment","shopify_create_product","shopify_delete_customer","shopify_delete_product","shopify_get_collection","shopify_get_customer","shopify_get_inventory_level","shopify_get_order","shopify_get_product","shopify_list_collections","shopify_list_customers","shopify_list_inventory_items","shopify_list_locations","shopify_list_orders","shopify_list_products","shopify_update_customer","shopify_update_order","shopify_update_product","similarweb_bounce_rate","similarweb_page_views","similarweb_pages_per_visit","similarweb_traffic_visits","similarweb_visit_duration","similarweb_website_overview","sixtyfour_enrich_company","sixtyfour_enrich_lead","sixtyfour_find_email","sixtyfour_find_phone","slack_add_reaction","slack_archive_conversation","slack_canvas","slack_create_channel_canvas","slack_create_conversation","slack_delete_canvas","slack_delete_message","slack_delete_scheduled_message","slack_download","slack_edit_canvas","slack_ephemeral_message","slack_get_canvas","slack_get_channel_history","slack_get_channel_info","slack_get_message","slack_get_permalink","slack_get_thread","slack_get_thread_replies","slack_get_user","slack_get_user_presence","slack_invite_to_conversation","slack_list_canvases","slack_list_channels","slack_list_members","slack_list_scheduled_messages","slack_list_users","slack_lookup_canvas_sections","slack_message","slack_message_reader","slack_open_view","slack_publish_view","slack_push_view","slack_remove_reaction","slack_rename_agent_session_v2","slack_rename_conversation","slack_schedule_message","slack_set_agent_session_status_v2","slack_set_conversation_purpose","slack_set_conversation_topic","slack_set_status","slack_set_suggested_prompts","slack_set_suggested_prompts_v2","slack_set_title","slack_update_message","slack_update_view","smartlead_add_email_accounts_to_campaign","smartlead_add_leads_to_campaign","smartlead_create_campaign","smartlead_create_lead_list","smartlead_delete_campaign","smartlead_delete_campaign_webhook","smartlead_delete_lead_from_campaign","smartlead_delete_lead_list","smartlead_duplicate_campaign","smartlead_export_campaign_leads","smartlead_get_campaign","smartlead_get_campaign_analytics","smartlead_get_campaign_analytics_by_date","smartlead_get_campaign_lead_statistics","smartlead_get_campaign_mailbox_statistics","smartlead_get_campaign_sequences","smartlead_get_campaign_statistics","smartlead_get_campaign_top_level_analytics_by_date","smartlead_get_campaign_webhook_summary","smartlead_get_lead_by_email","smartlead_get_lead_by_id","smartlead_get_lead_list","smartlead_get_lead_message_history","smartlead_list_campaign_email_accounts","smartlead_list_campaign_leads","smartlead_list_campaign_webhooks","smartlead_list_campaigns","smartlead_list_clients","smartlead_list_email_accounts","smartlead_list_inbox_replies","smartlead_list_lead_activities","smartlead_list_lead_categories","smartlead_list_lead_lists","smartlead_mark_lead_complete","smartlead_pause_lead","smartlead_remove_email_accounts_from_campaign","smartlead_resume_lead","smartlead_save_campaign_sequences","smartlead_unsubscribe_lead_from_campaign","smartlead_unsubscribe_lead_globally","smartlead_update_campaign_schedule","smartlead_update_campaign_settings","smartlead_update_campaign_status","smartlead_update_lead","smartlead_update_lead_category","smartlead_update_lead_list","smartlead_upsert_campaign_webhook","sms_send","smtp_send_mail","snowflake_alter_warehouse","snowflake_call_procedure","snowflake_cancel_statement","snowflake_cancel_task_run","snowflake_delete_rows","snowflake_execute_sql","snowflake_get_statement","snowflake_get_task","snowflake_get_task_run","snowflake_get_task_run_output","snowflake_get_warehouse","snowflake_insert_rows","snowflake_introspect_schema","snowflake_list_copy_history","snowflake_list_databases","snowflake_list_query_history","snowflake_list_schemas","snowflake_list_tables","snowflake_list_task_runs","snowflake_list_tasks","snowflake_list_warehouses","snowflake_load_data","snowflake_resume_task","snowflake_resume_warehouse","snowflake_run_task","snowflake_suspend_task","snowflake_suspend_warehouse","snowflake_unload_data","snowflake_update_rows","snowflake_upsert_rows","splunk_cancel_search_job","splunk_create_search_job","splunk_dispatch_saved_search","splunk_get_fired_alerts","splunk_get_saved_search","splunk_get_search_job","splunk_get_search_results","splunk_list_apps","splunk_list_fired_alerts","splunk_list_indexes","splunk_list_saved_searches","splunk_run_search","sportmonks_core_get_cities","sportmonks_core_get_city","sportmonks_core_get_continent","sportmonks_core_get_continents","sportmonks_core_get_countries","sportmonks_core_get_country","sportmonks_core_get_entity_filters","sportmonks_core_get_my_usage","sportmonks_core_get_region","sportmonks_core_get_regions","sportmonks_core_get_timezones","sportmonks_core_get_type","sportmonks_core_get_type_by_entity","sportmonks_core_get_types","sportmonks_core_search_cities","sportmonks_core_search_countries","sportmonks_core_search_regions","sportmonks_football_expected_by_player","sportmonks_football_expected_by_team","sportmonks_football_get_all_commentaries","sportmonks_football_get_all_fixtures","sportmonks_football_get_all_players","sportmonks_football_get_all_rivals","sportmonks_football_get_all_teams","sportmonks_football_get_all_transfer_rumours","sportmonks_football_get_all_transfers","sportmonks_football_get_brackets_by_season","sportmonks_football_get_coach","sportmonks_football_get_coaches","sportmonks_football_get_coaches_by_country","sportmonks_football_get_commentaries_by_fixture","sportmonks_football_get_current_leagues_by_team","sportmonks_football_get_expected_lineups_by_player","sportmonks_football_get_expected_lineups_by_team","sportmonks_football_get_extended_team_squad","sportmonks_football_get_fixture","sportmonks_football_get_fixtures_by_date","sportmonks_football_get_fixtures_by_date_range","sportmonks_football_get_fixtures_by_date_range_for_team","sportmonks_football_get_fixtures_by_ids","sportmonks_football_get_grouped_standings_by_round","sportmonks_football_get_head_to_head","sportmonks_football_get_inplay_livescores","sportmonks_football_get_latest_coaches","sportmonks_football_get_latest_fixtures","sportmonks_football_get_latest_livescores","sportmonks_football_get_latest_players","sportmonks_football_get_latest_totw","sportmonks_football_get_latest_transfers","sportmonks_football_get_league","sportmonks_football_get_leagues","sportmonks_football_get_leagues_by_country","sportmonks_football_get_leagues_by_date","sportmonks_football_get_leagues_by_team","sportmonks_football_get_live_leagues","sportmonks_football_get_live_probabilities","sportmonks_football_get_live_probabilities_by_fixture","sportmonks_football_get_live_standings_by_league","sportmonks_football_get_livescores","sportmonks_football_get_match_facts","sportmonks_football_get_match_facts_by_date_range","sportmonks_football_get_match_facts_by_fixture","sportmonks_football_get_match_facts_by_league","sportmonks_football_get_past_fixtures_by_tv_station","sportmonks_football_get_player","sportmonks_football_get_players_by_country","sportmonks_football_get_postmatch_news","sportmonks_football_get_postmatch_news_by_season","sportmonks_football_get_predictability_by_league","sportmonks_football_get_prematch_news","sportmonks_football_get_prematch_news_by_season","sportmonks_football_get_prematch_news_upcoming","sportmonks_football_get_probabilities","sportmonks_football_get_probabilities_by_fixture","sportmonks_football_get_referee","sportmonks_football_get_referees","sportmonks_football_get_referees_by_country","sportmonks_football_get_referees_by_season","sportmonks_football_get_rivals_by_team","sportmonks_football_get_round","sportmonks_football_get_round_statistics","sportmonks_football_get_rounds","sportmonks_football_get_rounds_by_season","sportmonks_football_get_schedules_by_season","sportmonks_football_get_schedules_by_season_and_team","sportmonks_football_get_schedules_by_team","sportmonks_football_get_season","sportmonks_football_get_seasons","sportmonks_football_get_seasons_by_team","sportmonks_football_get_stage","sportmonks_football_get_stage_statistics","sportmonks_football_get_stages","sportmonks_football_get_stages_by_season","sportmonks_football_get_standing_corrections_by_season","sportmonks_football_get_standings","sportmonks_football_get_standings_by_round","sportmonks_football_get_standings_by_season","sportmonks_football_get_state","sportmonks_football_get_states","sportmonks_football_get_team","sportmonks_football_get_team_rankings","sportmonks_football_get_team_rankings_by_date","sportmonks_football_get_team_rankings_by_team","sportmonks_football_get_team_squad","sportmonks_football_get_team_squad_by_season","sportmonks_football_get_teams_by_country","sportmonks_football_get_teams_by_season","sportmonks_football_get_topscorers_by_season","sportmonks_football_get_topscorers_by_stage","sportmonks_football_get_totw","sportmonks_football_get_totw_by_round","sportmonks_football_get_transfer","sportmonks_football_get_transfer_rumour","sportmonks_football_get_transfer_rumours_between_dates","sportmonks_football_get_transfer_rumours_by_player","sportmonks_football_get_transfer_rumours_by_team","sportmonks_football_get_transfers_between_dates","sportmonks_football_get_transfers_by_player","sportmonks_football_get_transfers_by_team","sportmonks_football_get_tv_station","sportmonks_football_get_tv_stations","sportmonks_football_get_tv_stations_by_fixture","sportmonks_football_get_upcoming_fixtures_by_market","sportmonks_football_get_upcoming_fixtures_by_tv_station","sportmonks_football_get_value_bets","sportmonks_football_get_value_bets_by_fixture","sportmonks_football_get_venue","sportmonks_football_get_venues","sportmonks_football_get_venues_by_season","sportmonks_football_search_coaches","sportmonks_football_search_fixtures","sportmonks_football_search_leagues","sportmonks_football_search_players","sportmonks_football_search_referees","sportmonks_football_search_rounds","sportmonks_football_search_seasons","sportmonks_football_search_stages","sportmonks_football_search_teams","sportmonks_football_search_venues","sportmonks_motorsport_get_all_fixtures","sportmonks_motorsport_get_current_leagues_by_team","sportmonks_motorsport_get_driver","sportmonks_motorsport_get_driver_standings","sportmonks_motorsport_get_driver_standings_by_season","sportmonks_motorsport_get_drivers","sportmonks_motorsport_get_drivers_by_country","sportmonks_motorsport_get_drivers_by_season","sportmonks_motorsport_get_fixture","sportmonks_motorsport_get_fixtures_by_date","sportmonks_motorsport_get_fixtures_by_date_range","sportmonks_motorsport_get_fixtures_by_ids","sportmonks_motorsport_get_laps_by_fixture","sportmonks_motorsport_get_laps_by_fixture_and_driver","sportmonks_motorsport_get_laps_by_fixture_and_lap","sportmonks_motorsport_get_latest_laps_by_fixture","sportmonks_motorsport_get_latest_pitstops_by_fixture","sportmonks_motorsport_get_latest_stints_by_fixture","sportmonks_motorsport_get_latest_updated_drivers","sportmonks_motorsport_get_latest_updated_fixtures","sportmonks_motorsport_get_league","sportmonks_motorsport_get_leagues","sportmonks_motorsport_get_leagues_by_country","sportmonks_motorsport_get_leagues_by_date","sportmonks_motorsport_get_leagues_by_live","sportmonks_motorsport_get_leagues_by_team","sportmonks_motorsport_get_livescores","sportmonks_motorsport_get_pitstops_by_fixture","sportmonks_motorsport_get_pitstops_by_fixture_and_driver","sportmonks_motorsport_get_pitstops_by_fixture_and_lap","sportmonks_motorsport_get_race_results_by_season_and_driver","sportmonks_motorsport_get_race_results_by_season_and_team","sportmonks_motorsport_get_schedules_by_season","sportmonks_motorsport_get_season","sportmonks_motorsport_get_seasons","sportmonks_motorsport_get_stage","sportmonks_motorsport_get_stages","sportmonks_motorsport_get_stages_by_season","sportmonks_motorsport_get_state","sportmonks_motorsport_get_states","sportmonks_motorsport_get_stints_by_fixture","sportmonks_motorsport_get_stints_by_fixture_and_driver","sportmonks_motorsport_get_stints_by_fixture_and_stint","sportmonks_motorsport_get_team","sportmonks_motorsport_get_team_standings","sportmonks_motorsport_get_team_standings_by_season","sportmonks_motorsport_get_teams","sportmonks_motorsport_get_teams_by_country","sportmonks_motorsport_get_teams_by_season","sportmonks_motorsport_get_venue","sportmonks_motorsport_get_venues","sportmonks_motorsport_get_venues_by_season","sportmonks_motorsport_search_drivers","sportmonks_motorsport_search_leagues","sportmonks_motorsport_search_stages","sportmonks_motorsport_search_teams","sportmonks_motorsport_search_venues","sportmonks_odds_get_all_historical_odds","sportmonks_odds_get_all_inplay_odds","sportmonks_odds_get_all_pre_match_odds","sportmonks_odds_get_all_premium_odds","sportmonks_odds_get_bookmaker","sportmonks_odds_get_bookmaker_event_ids_by_fixture","sportmonks_odds_get_bookmakers","sportmonks_odds_get_bookmakers_by_fixture","sportmonks_odds_get_inplay_odds_by_fixture","sportmonks_odds_get_inplay_odds_by_fixture_and_bookmaker","sportmonks_odds_get_inplay_odds_by_fixture_and_market","sportmonks_odds_get_last_updated_inplay_odds","sportmonks_odds_get_last_updated_pre_match_odds","sportmonks_odds_get_market","sportmonks_odds_get_markets","sportmonks_odds_get_pre_match_odds_by_fixture","sportmonks_odds_get_pre_match_odds_by_fixture_and_bookmaker","sportmonks_odds_get_pre_match_odds_by_fixture_and_market","sportmonks_odds_get_premium_odds_by_fixture","sportmonks_odds_get_premium_odds_by_fixture_and_bookmaker","sportmonks_odds_get_premium_odds_by_fixture_and_market","sportmonks_odds_get_updated_historical_odds_between","sportmonks_odds_get_updated_premium_odds_between","sportmonks_odds_search_bookmakers","sportmonks_odds_search_markets","spotify_add_playlist_cover","spotify_add_to_queue","spotify_add_tracks_to_playlist","spotify_check_following","spotify_check_playlist_followers","spotify_check_saved_albums","spotify_check_saved_audiobooks","spotify_check_saved_episodes","spotify_check_saved_shows","spotify_check_saved_tracks","spotify_create_playlist","spotify_follow_artists","spotify_follow_playlist","spotify_get_album","spotify_get_album_tracks","spotify_get_albums","spotify_get_artist","spotify_get_artist_albums","spotify_get_artist_top_tracks","spotify_get_artists","spotify_get_audiobook","spotify_get_audiobook_chapters","spotify_get_audiobooks","spotify_get_categories","spotify_get_current_user","spotify_get_currently_playing","spotify_get_devices","spotify_get_episode","spotify_get_episodes","spotify_get_followed_artists","spotify_get_markets","spotify_get_new_releases","spotify_get_playback_state","spotify_get_playlist","spotify_get_playlist_cover","spotify_get_playlist_tracks","spotify_get_queue","spotify_get_recently_played","spotify_get_saved_albums","spotify_get_saved_audiobooks","spotify_get_saved_episodes","spotify_get_saved_shows","spotify_get_saved_tracks","spotify_get_show","spotify_get_show_episodes","spotify_get_shows","spotify_get_top_artists","spotify_get_top_tracks","spotify_get_track","spotify_get_tracks","spotify_get_user_playlists","spotify_get_user_profile","spotify_pause","spotify_play","spotify_remove_saved_albums","spotify_remove_saved_audiobooks","spotify_remove_saved_episodes","spotify_remove_saved_shows","spotify_remove_saved_tracks","spotify_remove_tracks_from_playlist","spotify_reorder_playlist_items","spotify_replace_playlist_items","spotify_save_albums","spotify_save_audiobooks","spotify_save_episodes","spotify_save_shows","spotify_save_tracks","spotify_search","spotify_seek","spotify_set_repeat","spotify_set_shuffle","spotify_set_volume","spotify_skip_next","spotify_skip_previous","spotify_transfer_playback","spotify_unfollow_artists","spotify_unfollow_playlist","spotify_update_playlist","sqs_cancel_message_move_task","sqs_change_message_visibility","sqs_change_message_visibility_batch","sqs_create_queue","sqs_delete_message","sqs_delete_message_batch","sqs_delete_queue","sqs_get_queue_attributes","sqs_get_queue_url","sqs_list_dead_letter_source_queues","sqs_list_message_move_tasks","sqs_list_queue_tags","sqs_list_queues","sqs_purge_queue","sqs_receive_message","sqs_send","sqs_send_message_batch","sqs_set_queue_attributes","sqs_start_message_move_task","sqs_tag_queue","sqs_untag_queue","square_batch_retrieve_inventory_counts","square_cancel_invoice","square_cancel_payment","square_complete_payment","square_create_catalog_image","square_create_customer","square_create_invoice","square_create_order","square_create_payment","square_delete_catalog_object","square_delete_customer","square_delete_invoice","square_get_catalog_object","square_get_customer","square_get_invoice","square_get_location","square_get_order","square_get_payment","square_get_refund","square_list_catalog","square_list_customers","square_list_invoices","square_list_locations","square_list_payments","square_list_refunds","square_pay_order","square_publish_invoice","square_refund_payment","square_search_catalog_objects","square_search_customers","square_search_invoices","square_search_orders","square_update_customer","square_upsert_catalog_object","ssh_check_command_exists","ssh_check_file_exists","ssh_create_directory","ssh_delete_file","ssh_download_file","ssh_execute_command","ssh_execute_script","ssh_get_system_info","ssh_list_directory","ssh_move_rename","ssh_read_file_content","ssh_upload_file","ssh_write_file_content","ssm_cancel_command","ssm_delete_parameter","ssm_describe_automation_executions","ssm_describe_instance_information","ssm_describe_instance_patch_states","ssm_describe_instance_patches","ssm_describe_parameters","ssm_get_automation_execution","ssm_get_command_invocation","ssm_get_document","ssm_get_parameter","ssm_get_parameters","ssm_get_parameters_by_path","ssm_list_command_invocations","ssm_list_commands","ssm_list_compliance_items","ssm_list_compliance_summaries","ssm_list_documents","ssm_put_parameter","ssm_send_command","ssm_start_automation_execution","ssm_stop_automation_execution","stagehand_agent","stagehand_extract","stripe_cancel_payment_intent","stripe_cancel_subscription","stripe_capture_charge","stripe_capture_payment_intent","stripe_confirm_payment_intent","stripe_create_charge","stripe_create_customer","stripe_create_invoice","stripe_create_payment_intent","stripe_create_price","stripe_create_product","stripe_create_subscription","stripe_delete_customer","stripe_delete_invoice","stripe_delete_product","stripe_finalize_invoice","stripe_list_charges","stripe_list_customers","stripe_list_events","stripe_list_invoices","stripe_list_payment_intents","stripe_list_prices","stripe_list_products","stripe_list_subscriptions","stripe_pay_invoice","stripe_resume_subscription","stripe_retrieve_charge","stripe_retrieve_customer","stripe_retrieve_event","stripe_retrieve_invoice","stripe_retrieve_payment_intent","stripe_retrieve_price","stripe_retrieve_product","stripe_retrieve_subscription","stripe_search_charges","stripe_search_customers","stripe_search_invoices","stripe_search_payment_intents","stripe_search_prices","stripe_search_products","stripe_search_subscriptions","stripe_send_invoice","stripe_update_charge","stripe_update_customer","stripe_update_invoice","stripe_update_payment_intent","stripe_update_price","stripe_update_product","stripe_update_subscription","stripe_void_invoice","sts_assume_role","sts_assume_role_with_saml","sts_assume_role_with_web_identity","sts_get_access_key_info","sts_get_caller_identity","sts_get_session_token","stt_assemblyai","stt_assemblyai_v2","stt_deepgram","stt_deepgram_v2","stt_elevenlabs","stt_elevenlabs_v2","stt_gemini","stt_gemini_v2","stt_whisper","stt_whisper_v2","supabase_count","supabase_delete","supabase_get_row","supabase_insert","supabase_introspect","supabase_invoke_function","supabase_query","supabase_rpc","supabase_storage_copy","supabase_storage_create_bucket","supabase_storage_create_signed_upload_url","supabase_storage_create_signed_url","supabase_storage_delete","supabase_storage_delete_bucket","supabase_storage_download","supabase_storage_empty_bucket","supabase_storage_get_public_url","supabase_storage_list","supabase_storage_list_buckets","supabase_storage_move","supabase_storage_update_bucket","supabase_storage_upload","supabase_text_search","supabase_update","supabase_upsert","supabase_vector_search","table_batch_insert_rows","table_create","table_delete_row","table_delete_rows_by_filter","table_get_row","table_get_schema","table_insert_row","table_list","table_query_rows","table_query_rows_v2","table_update_row","table_update_rows_by_filter","table_upsert_row","tailscale_authorize_device","tailscale_create_auth_key","tailscale_delete_auth_key","tailscale_delete_device","tailscale_delete_user","tailscale_expire_device_key","tailscale_get_acl","tailscale_get_auth_key","tailscale_get_device","tailscale_get_device_routes","tailscale_get_dns_preferences","tailscale_get_dns_searchpaths","tailscale_list_auth_keys","tailscale_list_devices","tailscale_list_dns_nameservers","tailscale_list_users","tailscale_set_acl","tailscale_set_device_routes","tailscale_set_device_tags","tailscale_set_dns_nameservers","tailscale_set_dns_preferences","tailscale_set_dns_searchpaths","tailscale_suspend_user","tailscale_update_device_key","tavily_crawl","tavily_extract","tavily_map","tavily_search","telegram_copy_message","telegram_delete_message","telegram_edit_message_text","telegram_forward_message","telegram_get_chat","telegram_get_chat_member","telegram_message","telegram_pin_message","telegram_send_animation","telegram_send_audio","telegram_send_chat_action","telegram_send_contact","telegram_send_document","telegram_send_location","telegram_send_photo","telegram_send_poll","telegram_send_video","telegram_set_message_reaction","telegram_unpin_message","temporal_cancel_workflow","temporal_count_workflows","temporal_create_schedule","temporal_delete_schedule","temporal_describe_schedule","temporal_describe_task_queue","temporal_describe_workflow","temporal_get_workflow_history","temporal_list_schedules","temporal_list_workflows","temporal_pause_schedule","temporal_query_workflow","temporal_reset_workflow","temporal_signal_with_start","temporal_signal_workflow","temporal_start_workflow","temporal_terminate_workflow","temporal_trigger_schedule","temporal_unpause_schedule","temporal_update_workflow","textract_analyze_expense","textract_analyze_id","textract_parser","textract_parser_v2","thinking_tool","thrive_add_audience_managers","thrive_add_audience_members","thrive_add_user_tags","thrive_create_assignment","thrive_create_audience","thrive_create_completion","thrive_create_user","thrive_delete_assignment","thrive_delete_audience","thrive_delete_user","thrive_get_activity","thrive_get_assignment","thrive_get_audience","thrive_get_completion","thrive_get_content","thrive_get_cpd_category","thrive_get_cpd_entry","thrive_get_cpd_requirement","thrive_get_enrolment","thrive_get_skill_levels","thrive_get_tag","thrive_get_user_by_id","thrive_get_user_by_ref","thrive_list_assignments","thrive_list_audience_managers","thrive_list_audience_members","thrive_list_audiences","thrive_list_completions","thrive_list_enrolments","thrive_list_tags","thrive_query_activities","thrive_query_content","thrive_query_cpd_categories","thrive_query_cpd_entries","thrive_query_cpd_requirements","thrive_query_cpd_user_summaries","thrive_remove_audience_manager","thrive_remove_audience_member","thrive_remove_user_tags","thrive_replace_audience_managers","thrive_replace_audience_members","thrive_search_users","thrive_suspend_user","thrive_update_assignment","thrive_update_audience","thrive_update_user","thrive_update_user_skills","tiktok_get_post_status","tiktok_get_user","tiktok_list_videos","tiktok_query_videos","tiktok_upload_video_draft","tinybird_append_datasource","tinybird_delete_datasource_rows","tinybird_events","tinybird_get_job","tinybird_query","tinybird_query_pipe","tinybird_truncate_datasource","tinyfish_cancel_run","tinyfish_fetch","tinyfish_get_run","tinyfish_list_profiles","tinyfish_list_runs","tinyfish_list_vault_items","tinyfish_run","tinyfish_run_async","tinyfish_search","trello_add_checklist","trello_add_checklist_item","trello_add_comment","trello_add_label","trello_add_member","trello_create_board","trello_create_card","trello_create_list","trello_delete_card","trello_get_actions","trello_get_board","trello_get_card","trello_list_cards","trello_list_lists","trello_list_members","trello_remove_label","trello_remove_member","trello_search","trello_update_card","trello_update_checklist_item","trello_update_list","trigger_dev_activate_schedule","trigger_dev_add_run_tags","trigger_dev_batch_trigger_task","trigger_dev_cancel_run","trigger_dev_complete_waitpoint_token","trigger_dev_create_env_var","trigger_dev_create_schedule","trigger_dev_create_waitpoint_token","trigger_dev_deactivate_schedule","trigger_dev_delete_env_var","trigger_dev_delete_schedule","trigger_dev_execute_query","trigger_dev_get_batch","trigger_dev_get_batch_results","trigger_dev_get_deployment","trigger_dev_get_env_var","trigger_dev_get_latest_deployment","trigger_dev_get_query_schema","trigger_dev_get_queue","trigger_dev_get_run","trigger_dev_get_run_events","trigger_dev_get_run_result","trigger_dev_get_run_trace","trigger_dev_get_schedule","trigger_dev_get_waitpoint_token","trigger_dev_import_env_vars","trigger_dev_list_deployments","trigger_dev_list_env_vars","trigger_dev_list_queues","trigger_dev_list_runs","trigger_dev_list_schedules","trigger_dev_list_timezones","trigger_dev_list_waitpoint_tokens","trigger_dev_override_queue_concurrency","trigger_dev_pause_queue","trigger_dev_promote_deployment","trigger_dev_replay_run","trigger_dev_reschedule_run","trigger_dev_reset_queue_concurrency","trigger_dev_resume_queue","trigger_dev_trigger_task","trigger_dev_update_env_var","trigger_dev_update_run_metadata","trigger_dev_update_schedule","tts_azure","tts_cartesia","tts_deepgram","tts_elevenlabs","tts_google","tts_openai","tts_playht","twilio_send_sms","twilio_voice_get_recording","twilio_voice_list_calls","twilio_voice_make_call","typeform_create_form","typeform_delete_form","typeform_files","typeform_get_form","typeform_insights","typeform_list_forms","typeform_responses","typeform_update_form","upstash_redis_command","upstash_redis_delete","upstash_redis_exists","upstash_redis_expire","upstash_redis_get","upstash_redis_hget","upstash_redis_hgetall","upstash_redis_hset","upstash_redis_incr","upstash_redis_incrby","upstash_redis_keys","upstash_redis_lpush","upstash_redis_lrange","upstash_redis_set","upstash_redis_setnx","upstash_redis_ttl","uptimerobot_create_alert_contact","uptimerobot_create_maintenance_window","uptimerobot_create_monitor","uptimerobot_create_psp","uptimerobot_delete_alert_contact","uptimerobot_delete_maintenance_window","uptimerobot_delete_monitor","uptimerobot_delete_psp","uptimerobot_get_account","uptimerobot_get_alert_contact","uptimerobot_get_incident","uptimerobot_get_maintenance_window","uptimerobot_get_monitor","uptimerobot_get_psp","uptimerobot_list_alert_contacts","uptimerobot_list_incidents","uptimerobot_list_maintenance_windows","uptimerobot_list_monitors","uptimerobot_list_psps","uptimerobot_pause_monitor","uptimerobot_start_monitor","uptimerobot_update_maintenance_window","uptimerobot_update_monitor","uptimerobot_update_psp","vanta_download_document_file","vanta_get_control","vanta_get_document","vanta_get_framework","vanta_get_person","vanta_get_policy","vanta_get_risk_scenario","vanta_get_test","vanta_get_vendor","vanta_get_vulnerable_asset","vanta_list_control_documents","vanta_list_control_tests","vanta_list_controls","vanta_list_document_uploads","vanta_list_documents","vanta_list_framework_controls","vanta_list_frameworks","vanta_list_monitored_computers","vanta_list_people","vanta_list_policies","vanta_list_risk_scenarios","vanta_list_test_entities","vanta_list_tests","vanta_list_vendors","vanta_list_vulnerabilities","vanta_list_vulnerability_remediations","vanta_list_vulnerable_assets","vanta_submit_document","vanta_upload_document_file","vercel_add_domain","vercel_add_project_domain","vercel_cancel_deployment","vercel_create_alias","vercel_create_check","vercel_create_deployment","vercel_create_dns_record","vercel_create_edge_config","vercel_create_env_var","vercel_create_project","vercel_create_webhook","vercel_delete_alias","vercel_delete_deployment","vercel_delete_dns_record","vercel_delete_domain","vercel_delete_edge_config","vercel_delete_env_var","vercel_delete_project","vercel_delete_webhook","vercel_get_alias","vercel_get_check","vercel_get_deployment","vercel_get_deployment_events","vercel_get_domain","vercel_get_domain_config","vercel_get_edge_config","vercel_get_edge_config_items","vercel_get_env_vars","vercel_get_project","vercel_get_team","vercel_get_user","vercel_get_webhook","vercel_list_aliases","vercel_list_checks","vercel_list_deployment_files","vercel_list_deployments","vercel_list_dns_records","vercel_list_domains","vercel_list_edge_configs","vercel_list_project_domains","vercel_list_projects","vercel_list_team_members","vercel_list_teams","vercel_list_webhooks","vercel_pause_project","vercel_promote_deployment","vercel_remove_project_domain","vercel_rerequest_check","vercel_unpause_project","vercel_update_check","vercel_update_dns_record","vercel_update_edge_config_items","vercel_update_env_var","vercel_update_project","vercel_update_project_domain","vercel_verify_project_domain","video_falai","video_luma","video_minimax","video_runway","video_veo","vision_tool","vision_tool_v2","wealthbox_read_contact","wealthbox_read_note","wealthbox_read_task","wealthbox_write_contact","wealthbox_write_note","wealthbox_write_task","webflow_create_item","webflow_delete_item","webflow_get_item","webflow_list_items","webflow_update_item","webhook_request","whatsapp_get_media","whatsapp_mark_read","whatsapp_send_interactive","whatsapp_send_media","whatsapp_send_message","whatsapp_send_reaction","whatsapp_send_template","whatsapp_upload_media","wikipedia_content","wikipedia_random","wikipedia_search","wikipedia_summary","windchill_check_in_document","windchill_check_in_documents","windchill_check_out_document","windchill_check_out_documents","windchill_create_document","windchill_create_documents","windchill_delete_document","windchill_delete_documents","windchill_download_attachment","windchill_download_primary_content","windchill_get_document","windchill_get_document_structure","windchill_get_primary_content","windchill_get_valid_state_transitions","windchill_list_attachments","windchill_list_documents","windchill_revise_document","windchill_revise_documents","windchill_set_lifecycle_state","windchill_undo_check_out_document","windchill_undo_check_out_documents","windchill_update_common_properties","windchill_update_document","windchill_update_document_security_labels","windchill_update_documents","windchill_upload_attachments","windchill_upload_primary_content","wiza_company_enrichment","wiza_get_credits","wiza_individual_reveal","wiza_prospect_search","wordpress_create_category","wordpress_create_comment","wordpress_create_page","wordpress_create_post","wordpress_create_tag","wordpress_delete_category","wordpress_delete_comment","wordpress_delete_media","wordpress_delete_page","wordpress_delete_post","wordpress_delete_tag","wordpress_get_category","wordpress_get_current_user","wordpress_get_media","wordpress_get_page","wordpress_get_post","wordpress_get_tag","wordpress_get_user","wordpress_list_categories","wordpress_list_comments","wordpress_list_media","wordpress_list_pages","wordpress_list_posts","wordpress_list_tags","wordpress_list_users","wordpress_search_content","wordpress_update_category","wordpress_update_comment","wordpress_update_page","wordpress_update_post","wordpress_update_tag","wordpress_upload_media","workday_assign_onboarding","workday_change_job","workday_create_prehire","workday_get_compensation","workday_get_organizations","workday_get_worker","workday_hire_employee","workday_list_workers","workday_terminate_worker","workday_update_worker","workflow_executor","x_create_bookmark","x_create_tweet","x_delete_bookmark","x_delete_tweet","x_get_blocking","x_get_bookmarks","x_get_followers","x_get_following","x_get_liked_tweets","x_get_liking_users","x_get_me","x_get_personalized_trends","x_get_quote_tweets","x_get_retweeted_by","x_get_trends_by_woeid","x_get_tweets_by_ids","x_get_usage","x_get_user_mentions","x_get_user_timeline","x_get_user_tweets","x_hide_reply","x_manage_block","x_manage_follow","x_manage_like","x_manage_mute","x_manage_retweet","x_read","x_search","x_search_tweets","x_search_users","x_user","x_write","youtube_channel_info","youtube_channel_playlists","youtube_channel_videos","youtube_comments","youtube_playlist_items","youtube_search","youtube_trending","youtube_video_categories","youtube_video_details","zendesk_autocomplete_organizations","zendesk_create_organization","zendesk_create_organizations_bulk","zendesk_create_ticket","zendesk_create_tickets_bulk","zendesk_create_user","zendesk_create_users_bulk","zendesk_delete_organization","zendesk_delete_ticket","zendesk_delete_user","zendesk_get_current_user","zendesk_get_organization","zendesk_get_organizations","zendesk_get_ticket","zendesk_get_tickets","zendesk_get_user","zendesk_get_users","zendesk_merge_tickets","zendesk_search","zendesk_search_count","zendesk_search_users","zendesk_update_organization","zendesk_update_ticket","zendesk_update_tickets_bulk","zendesk_update_user","zendesk_update_users_bulk","zep_add_messages","zep_add_user","zep_create_thread","zep_delete_thread","zep_get_context","zep_get_messages","zep_get_threads","zep_get_user","zep_get_user_threads","zerobounce_get_credits","zerobounce_verify_email","zoho_desk_add_comment","zoho_desk_get_attachment","zoho_desk_get_contact","zoho_desk_get_thread","zoho_desk_get_ticket","zoho_desk_list_comments","zoho_desk_list_organizations","zoho_desk_list_threads","zoho_desk_list_tickets","zoho_desk_update_ticket","zoom_create_meeting","zoom_delete_meeting","zoom_delete_recording","zoom_get_meeting","zoom_get_meeting_invitation","zoom_get_meeting_recordings","zoom_list_meetings","zoom_list_past_participants","zoom_list_recordings","zoom_update_meeting","zoominfo_enrich_companies","zoominfo_enrich_contacts","zoominfo_search_companies","zoominfo_search_contacts","zoominfo_search_intent","zoominfo_search_news"]' ) export default toolIds diff --git a/apps/sim/tools/generated/tool-metadata.ts b/apps/sim/tools/generated/tool-metadata.ts index ec9d306adcd..02bba0790c5 100644 --- a/apps/sim/tools/generated/tool-metadata.ts +++ b/apps/sim/tools/generated/tool-metadata.ts @@ -3,7 +3,7 @@ /** Serializable metadata for every built-in tool, keyed by tool id. */ const toolMetadata: Record = JSON.parse( - '{"a2a_cancel_task":{"id":"a2a_cancel_task","name":"A2A Cancel Task","description":"Request cancellation of an in-progress A2A task.","version":"1.0.0","params":{"agentUrl":{"type":"string","required":true,"visibility":"user-only","description":"The A2A agent endpoint URL"},"taskId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The task ID to cancel"},"apiKey":{"type":"string","required":false,"visibility":"user-only","description":"API key for authentication (if required)"}},"hostedApiKey":"none"},"a2a_get_agent_card":{"id":"a2a_get_agent_card","name":"A2A Get Agent Card","description":"Fetch the Agent Card (discovery document) for an external A2A agent.","version":"1.0.0","params":{"agentUrl":{"type":"string","required":true,"visibility":"user-only","description":"The A2A agent endpoint URL"},"apiKey":{"type":"string","required":false,"visibility":"user-only","description":"API key for authentication (if required)"}},"hostedApiKey":"none"},"a2a_get_task":{"id":"a2a_get_task","name":"A2A Get Task","description":"Retrieve the current state and result of an A2A task.","version":"1.0.0","params":{"agentUrl":{"type":"string","required":true,"visibility":"user-only","description":"The A2A agent endpoint URL"},"taskId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The task ID to retrieve"},"historyLength":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of history messages to include"},"apiKey":{"type":"string","required":false,"visibility":"user-only","description":"API key for authentication (if required)"}},"hostedApiKey":"none"},"a2a_send_message":{"id":"a2a_send_message","name":"A2A Send Message","description":"Send a message to an external A2A agent and return its response.","version":"1.0.0","params":{"agentUrl":{"type":"string","required":true,"visibility":"user-only","description":"The A2A agent endpoint URL"},"message":{"type":"string","required":true,"visibility":"user-or-llm","description":"The message text to send"},"data":{"type":"json","required":false,"visibility":"user-or-llm","description":"Optional structured JSON data to attach"},"files":{"type":"json","required":false,"visibility":"user-or-llm","description":"Optional files to attach"},"taskId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Existing task ID to continue"},"contextId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Conversation context ID to continue"},"apiKey":{"type":"string","required":false,"visibility":"user-only","description":"API key for authentication (if required)"}},"hostedApiKey":"none"},"affinity_batch_update_entity_fields":{"id":"affinity_batch_update_entity_fields","name":"Affinity Batch Update Entity Fields","description":"Write up to 100 non-list field values on one company or person in a single request.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which entity to write the fields on: companies or persons"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company or person"},"updates":{"type":"json","required":true,"visibility":"user-or-llm","description":"Up to 100 field updates as [{\\"id\\":\\"\\",\\"value\\":{\\"type\\":\\"…\\",\\"data\\":…}}], using the same value shapes as a single field update"}},"hostedApiKey":"none"},"affinity_batch_update_list_entry_fields":{"id":"affinity_batch_update_list_entry_fields","name":"Affinity Batch Update List Entry Fields","description":"Write up to 100 field values on one list row in a single request. Requires the \\"Export data from Lists\\" permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"listEntryId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list entry ID"},"updates":{"type":"json","required":true,"visibility":"user-or-llm","description":"Up to 100 field updates as [{\\"id\\":\\"\\",\\"value\\":{\\"type\\":\\"…\\",\\"data\\":…}}], using the same value shapes as a single field update"}},"hostedApiKey":"none"},"affinity_create_list":{"id":"affinity_create_list","name":"Affinity Create List","description":"Create a list. Its type fixes which entities it can hold, and the API key holder becomes its creator and owner.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the new list"},"type":{"type":"string","required":true,"visibility":"user-or-llm","description":"Entity kind the list holds: company, opportunity, or person"},"isPublic":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Whether everyone in the organization can see the list"}},"hostedApiKey":"none"},"affinity_create_list_field_dropdown_option":{"id":"affinity_create_list_field_dropdown_option","name":"Affinity Create List Field Dropdown Option","description":"Add a selectable option to a dropdown field on a list. A ranked or status option also needs a rank and a color.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown field ID on that list"},"type":{"type":"string","required":true,"visibility":"user-or-llm","description":"Kind of option to create, matching the field. dropdown takes only a label; ranked-dropdown also requires rank and color; status-dropdown additionally requires a status category. Sending a field the kind does not accept is rejected"},"text":{"type":"string","required":true,"visibility":"user-or-llm","description":"The option label"},"rank":{"type":"number","required":false,"visibility":"user-or-llm","description":"Sort order. Required on a ranked-dropdown or status-dropdown option"},"color":{"type":"string","required":false,"visibility":"user-or-llm","description":"Option color: white, gray, blue, green, purple, orange, or red. Required on a ranked-dropdown or status-dropdown option"},"statusCategory":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pipeline meaning of the option: open, won, lost, or on-hold. Status-dropdown options only"},"winRate":{"type":"number","required":false,"visibility":"user-or-llm","description":"Expected win rate of the status. Status-dropdown options only"}},"hostedApiKey":"none"},"affinity_create_merge":{"id":"affinity_create_merge","name":"Affinity Create Merge","description":"Fold a duplicate company or person into the record you are keeping. The merge runs asynchronously — poll the returned task to see it finish. Requires the \\"Manage duplicates\\" permission and an admin role.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"What to merge: companies or persons"},"primaryId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to keep"},"duplicateId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the duplicate record to fold in"}},"hostedApiKey":"none"},"affinity_create_note":{"id":"affinity_create_note","name":"Affinity Create Note","description":"Write a note — attached to companies, persons, and opportunities, anchored to a meeting, call, or chat message, or posted as a reply to an existing note.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"type":{"type":"string","required":true,"visibility":"user-or-llm","description":"Note shape: entities to attach it to records, interaction to anchor it to a meeting, call, or chat message, or user-reply to reply to a note"},"html":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note body as HTML"},"companyIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Companies to attach the note to, e.g. [1, 2]. Not used on a reply"},"personIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Persons to attach the note to, e.g. [1, 2]. Not used on a reply"},"opportunityIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Opportunities to attach the note to, e.g. [1, 2]. Not used on a reply"},"interactionId":{"type":"string","required":false,"visibility":"user-or-llm","description":"The interaction to anchor the note to. Required for an interaction note"},"interactionType":{"type":"string","required":false,"visibility":"user-or-llm","description":"Kind of the anchoring interaction: meeting, call, or chat-message. Required for an interaction note"},"parentId":{"type":"string","required":false,"visibility":"user-or-llm","description":"The note being replied to. Required for a user-reply note"},"creatorId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Attribute the note to another internal person. Defaults to the API key holder"},"createdAt":{"type":"string","required":false,"visibility":"user-or-llm","description":"Backdate the note to this ISO 8601 timestamp"}},"hostedApiKey":"none"},"affinity_create_reminder":{"id":"affinity_create_reminder","name":"Affinity Create Reminder","description":"Create a reminder on one company, person, or opportunity. A recurring reminder resets whenever the chosen signal happens instead of firing once.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"type":{"type":"string","required":true,"visibility":"user-or-llm","description":"one-time to fire once, or recurring to reset on a signal"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"What the reminder is about: company, person, or opportunity"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company, person, or opportunity"},"dueDate":{"type":"string","required":false,"visibility":"user-or-llm","description":"When the reminder is due, as an ISO 8601 timestamp. Required for a one-time reminder; on a recurring one Affinity computes it from the period when omitted"},"content":{"type":"string","required":false,"visibility":"user-or-llm","description":"What the reminder says"},"ownerId":{"type":"string","required":true,"visibility":"user-or-llm","description":"User the reminder is assigned to. Must be an internal user. The API key holder is recorded as the creator, which is a separate field"},"resetTrigger":{"type":"string","required":false,"visibility":"user-or-llm","description":"What restarts a recurring reminder: interaction, email, or event. Required when the type is recurring"},"periodDays":{"type":"number","required":false,"visibility":"user-or-llm","description":"Days between firings of a recurring reminder. Required when the type is recurring"}},"hostedApiKey":"none"},"affinity_delete_list_field_dropdown_option":{"id":"affinity_delete_list_field_dropdown_option","name":"Affinity Delete List Field Dropdown Option","description":"Permanently delete a dropdown option on a list field. Every list entry currently set to it is cleared, and those values cannot be recovered.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown field ID on that list"},"dropdownOptionId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown option ID to delete"}},"hostedApiKey":"none"},"affinity_delete_note":{"id":"affinity_delete_note","name":"Affinity Delete Note","description":"Delete a note you created. Deleting a root note also deletes its replies; deleting a reply removes only that reply.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"noteId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note ID to delete"}},"hostedApiKey":"none"},"affinity_get_company":{"id":"affinity_get_company","name":"Affinity Get Company","description":"Look up one company by ID. Field data is returned only for the Field IDs or Field Types asked for.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"companyId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The company ID"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, or relationship-intelligence. Mutually exclusive with Field IDs"}},"hostedApiKey":"none"},"affinity_get_current_user":{"id":"affinity_get_current_user","name":"Affinity Get Current User","description":"Verify an Affinity API key and return the tenant, the user behind the key, and the scopes the grant carries.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"}},"hostedApiKey":"none"},"affinity_get_entity_field_value":{"id":"affinity_get_entity_field_value","name":"Affinity Get Entity Field Value","description":"Read one non-list field value from a company or person.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which entity to read the field from: companies or persons"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company or person"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The field ID to read"}},"hostedApiKey":"none"},"affinity_get_list":{"id":"affinity_get_list","name":"Affinity Get List","description":"Read one list — its name, type, owner, and privacy setting.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"}},"hostedApiKey":"none"},"affinity_get_list_entry":{"id":"affinity_get_list_entry","name":"Affinity Get List Entry","description":"Read one row of a list with its entity. Field data is returned only for the Field IDs or Field Types asked for.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"listEntryId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list entry ID"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, list, or relationship-intelligence. Mutually exclusive with Field IDs"}},"hostedApiKey":"none"},"affinity_get_list_entry_field":{"id":"affinity_get_list_entry_field","name":"Affinity Get List Entry Field","description":"Read one field value on a list row.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"listEntryId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list entry ID"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The field ID to read"}},"hostedApiKey":"none"},"affinity_get_list_field_dropdown_option":{"id":"affinity_get_list_field_dropdown_option","name":"Affinity Get List Field Dropdown Option","description":"Read one dropdown option on a list field.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown field ID on that list"},"dropdownOptionId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown option ID"}},"hostedApiKey":"none"},"affinity_get_merge":{"id":"affinity_get_merge","name":"Affinity Get Merge","description":"Read the status of one company or person merge, including why it failed if it did.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which merge to read: companies or persons"},"mergeId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The merge ID"}},"hostedApiKey":"none"},"affinity_get_merge_task":{"id":"affinity_get_merge_task","name":"Affinity Get Merge Task","description":"Read one merge task and how its merges are progressing. Poll this after starting a merge.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which merge task to read: companies or persons"},"taskId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The merge task ID"}},"hostedApiKey":"none"},"affinity_get_note":{"id":"affinity_get_note","name":"Affinity Get Note","description":"Read one note with its body, author, mentions, and attached records.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"noteId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note ID"},"includes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Extra properties to return, e.g. [\\"repliesCount\\",\\"personsPreview\\",\\"companiesPreview\\",\\"opportunitiesPreview\\"]. Those four fields are omitted unless requested here"}},"hostedApiKey":"none"},"affinity_get_opportunity":{"id":"affinity_get_opportunity","name":"Affinity Get Opportunity","description":"Read one opportunity and the list it belongs to. Its field data lives on the list entry.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"opportunityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The opportunity ID"}},"hostedApiKey":"none"},"affinity_get_person":{"id":"affinity_get_person","name":"Affinity Get Person","description":"Look up one person by ID. Field data is returned only for the Field IDs or Field Types asked for.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"personId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The person ID"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, or relationship-intelligence. Mutually exclusive with Field IDs"}},"hostedApiKey":"none"},"affinity_get_saved_view":{"id":"affinity_get_saved_view","name":"Affinity Get Saved View","description":"Read one saved view — its name, kind, and creation date.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"viewId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The saved view ID"}},"hostedApiKey":"none"},"affinity_get_transcript":{"id":"affinity_get_transcript","name":"Affinity Get Transcript","description":"Read one transcript with its first 100 fragments. Page the fragments endpoint for a longer meeting.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"transcriptId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The transcript ID"}},"hostedApiKey":"none"},"affinity_get_user":{"id":"affinity_get_user","name":"Affinity Get User","description":"Read one internal user. A user and their person record share the same numeric ID, so a person ID works here.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"userId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The user ID, which is also their person ID"}},"hostedApiKey":"none"},"affinity_list_calls":{"id":"affinity_list_calls","name":"Affinity List Calls","description":"Page through logged calls and their participants. Only calls the API key holder can see are returned.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_chat_messages":{"id":"affinity_list_chat_messages","name":"Affinity List Chat Messages","description":"Page through logged chat messages and their participants. Only messages the API key holder can see are returned.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_companies":{"id":"affinity_list_companies","name":"Affinity List Companies","description":"Page through companies. Companies come back without field data unless Field IDs or Field Types asks for it.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"ids":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict the page to these company IDs, e.g. [1, 2, 3]"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, or relationship-intelligence. Mutually exclusive with Field IDs"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_coworker_connections":{"id":"affinity_list_coworker_connections","name":"Affinity List Coworker Connections","description":"Find warm paths into a company through shared work history: who in your Affinity data once worked alongside the people you want to reach. Grouped by target, strongest first.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":true,"visibility":"user-or-llm","description":"Required scope. The only supported filter is target.currentCompany.id, e.g. \\"target.currentCompany.id=123\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of targets to return per page, 1-50. Defaults to 20"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"hostedApiKey":"none"},"affinity_list_emails":{"id":"affinity_list_emails","name":"Affinity List Emails","description":"Page through email metadata — subject, participants, and timestamps. Affinity never exposes email bodies through the API.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_entity_field_values":{"id":"affinity_list_entity_field_values","name":"Affinity List Entity Field Values","description":"Page through a company\'s or person\'s non-list field values. List fields are not returned here — read those through the list entry.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which entity to read field values from: companies or persons"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company or person"},"ids":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict to these field IDs. Mutually exclusive with Field Types"},"types":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict to these field categories: enriched, global, relationship-intelligence. Mutually exclusive with Field IDs"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 20"}},"hostedApiKey":"none"},"affinity_list_entity_list_entries":{"id":"affinity_list_entity_list_entries","name":"Affinity List Entity List Entries","description":"Page through a company\'s or person\'s rows across every list, each carrying that list\'s field values and when the entity was added.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which entity to look up the rows of: companies or persons"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company or person"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_entity_lists":{"id":"affinity_list_entity_lists","name":"Affinity List Entity Lists","description":"List every list a company or person appears on that the caller can view.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which entity to look up the lists of: companies or persons"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company or person"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_entity_notes":{"id":"affinity_list_entity_notes","name":"Affinity List Entity Notes","description":"List the notes relevant to one company, person, or opportunity — directly attached notes plus notes reaching it through its people and meetings.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which entity the notes hang off: companies, persons, or opportunities"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company, person, or opportunity"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"hostedApiKey":"none"},"affinity_list_entity_relationships":{"id":"affinity_list_entity_relationships","name":"Affinity List Entity Relationships","description":"List who knows a company or person, scored 0.0 to 1.0 by how much the two actually interact. Strongest first by default.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which entity to look up relationships for: companies or persons"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company or person"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression. This endpoint filters on interactionScore only, e.g. \\"interactionScore>=0.5\\""},"orderBy":{"type":"json","required":false,"visibility":"user-or-llm","description":"Sort order: [\\"interactionScore\\"] for weakest first, [\\"-interactionScore\\"] for strongest first (the default)"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"hostedApiKey":"none"},"affinity_list_field_dropdown_options":{"id":"affinity_list_field_dropdown_options","name":"Affinity List Field Dropdown Options","description":"List the selectable options on a dropdown or ranked-dropdown company or person field. Writing such a field needs the option ID, not its text.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which field family the field belongs to: companies or persons"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown or ranked-dropdown field ID"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_field_metadata":{"id":"affinity_list_field_metadata","name":"Affinity List Field Metadata","description":"List the non-list company or person fields, with the value type, filter operators, and sort support of each. Start here to find the Field IDs the read and write tools take.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which fields to describe: companies or persons"},"includes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Extra properties to return: [\\"filterability\\",\\"sortability\\"]. Both are omitted unless requested here"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression. This endpoint filters on name only, e.g. \\"name=~Status\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_field_value_changes":{"id":"affinity_list_field_value_changes","name":"Affinity List Field Value Changes","description":"Page through field value changes across the whole workspace. Built for delta sync: follow nextCursor to the end of a run, then resume from the last cursor next time.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression over field.id, listEntry.id, changer.id, changedAt, or actionType. Resume a sync with e.g. \\"changedAt>2026-06-01T12:00:00Z\\""},"orderBy":{"type":"json","required":false,"visibility":"user-or-llm","description":"Sort order: [\\"changedAt\\"] for oldest first (the default), [\\"-changedAt\\"] for newest first"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_investor_executive_connections":{"id":"affinity_list_investor_executive_connections","name":"Affinity List Investor Executive Connections","description":"Find warm paths into a company through investment history: which investors in your Affinity data backed a company the people you want to reach once led. Grouped by target, strongest first.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":true,"visibility":"user-or-llm","description":"Required scope. The only supported filter is target.currentCompany.id, e.g. \\"target.currentCompany.id=123\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of targets to return per page, 1-50. Defaults to 20"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"hostedApiKey":"none"},"affinity_list_list_entries":{"id":"affinity_list_list_entries","name":"Affinity List List Entries","description":"Page through the rows of a list. Rows come back without field data unless Field IDs or Field Types asks for it.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, list, or relationship-intelligence. Mutually exclusive with Field IDs"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_list_entry_field_value_changes":{"id":"affinity_list_list_entry_field_value_changes","name":"Affinity List List Entry Field Value Changes","description":"Page through the history of one list row — who changed which field, when, and to what.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"listEntryId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list entry ID"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression over field.id, changer.id, changedAt, or actionType, e.g. \\"field.id=field-1234\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_list_entry_fields":{"id":"affinity_list_list_entry_fields","name":"Affinity List List Entry Fields","description":"Page through every field value on one list row, including the list-specific columns. All fields are returned unless narrowed.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"listEntryId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list entry ID"},"ids":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict to these field IDs. Mutually exclusive with Field Types"},"types":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict to these field categories: enriched, global, list, relationship-intelligence. Mutually exclusive with Field IDs"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 20"}},"hostedApiKey":"none"},"affinity_list_list_field_dropdown_options":{"id":"affinity_list_list_field_dropdown_options","name":"Affinity List List Field Dropdown Options","description":"List the selectable options on a dropdown, ranked-dropdown, or status-dropdown field of a list.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown field ID on that list"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_list_fields":{"id":"affinity_list_list_fields","name":"Affinity List List Fields","description":"List the fields available on one list, including its list-specific columns. Use these Field IDs when reading or writing list entries.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"includes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Extra properties to return: [\\"filterability\\",\\"sortability\\"]. Both are omitted unless requested here"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression. This endpoint filters on name only, e.g. \\"name=~Stage\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_lists":{"id":"affinity_list_lists","name":"Affinity List Lists","description":"Page through the lists in the organization that the caller can view.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"term":{"type":"string","required":false,"visibility":"user-or-llm","description":"Case-insensitive substring match on the list name"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_meetings":{"id":"affinity_list_meetings","name":"Affinity List Meetings","description":"Page through past and upcoming meetings with their organizer and attendees.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_merge_tasks":{"id":"affinity_list_merge_tasks","name":"Affinity List Merge Tasks","description":"Page through merge tasks, each summarizing how many of its merges are in progress, succeeded, or failed.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which merge tasks to list: companies or persons"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression. This endpoint filters on status only, e.g. \\"status=in-progress\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_merges":{"id":"affinity_list_merges","name":"Affinity List Merges","description":"Page through the company or person merges the organization has run, with the status and the records involved in each.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which merges to list: companies or persons"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression over status or taskId, e.g. \\"status=failed\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_note_attached_companies":{"id":"affinity_list_note_attached_companies","name":"Affinity List Note Attached Companies","description":"List the companies directly attached to one note.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"noteId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note ID"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"hostedApiKey":"none"},"affinity_list_note_attached_opportunities":{"id":"affinity_list_note_attached_opportunities","name":"Affinity List Note Attached Opportunities","description":"List the opportunities directly attached to one note.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"noteId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note ID"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"hostedApiKey":"none"},"affinity_list_note_attached_persons":{"id":"affinity_list_note_attached_persons","name":"Affinity List Note Attached Persons","description":"List the persons directly attached to one note.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"noteId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note ID"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"hostedApiKey":"none"},"affinity_list_note_replies":{"id":"affinity_list_note_replies","name":"Affinity List Note Replies","description":"Page through the replies on one note, including AI Notetaker replies.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"noteId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note ID whose replies to read"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"hostedApiKey":"none"},"affinity_list_notes":{"id":"affinity_list_notes","name":"Affinity List Notes","description":"Page through every note the caller can see. Replies are excluded.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"includes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Extra properties to return, e.g. [\\"repliesCount\\",\\"personsPreview\\",\\"companiesPreview\\",\\"opportunitiesPreview\\"]. Those four fields are omitted unless requested here"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"hostedApiKey":"none"},"affinity_list_opportunities":{"id":"affinity_list_opportunities","name":"Affinity List Opportunities","description":"Page through opportunities. Field data lives on the list entry, not here — read it through the list or saved view the opportunity belongs to.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"ids":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict the page to these opportunity IDs, e.g. [1, 2, 3]"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_persons":{"id":"affinity_list_persons","name":"Affinity List Persons","description":"Page through persons. Persons come back without field data unless Field IDs or Field Types asks for it.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"ids":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict the page to these person IDs, e.g. [1, 2, 3]"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, or relationship-intelligence. Mutually exclusive with Field IDs"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_reminders":{"id":"affinity_list_reminders","name":"Affinity List Reminders","description":"Page through the reminders the caller can see. Filter by status to surface what is overdue.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"hostedApiKey":"none"},"affinity_list_saved_view_entries":{"id":"affinity_list_saved_view_entries","name":"Affinity List Saved View Entries","description":"Page through the rows of a saved view. The view\'s own filters and columns decide which rows and which field data come back.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"viewId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The saved view ID"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_saved_views":{"id":"affinity_list_saved_views","name":"Affinity List Saved Views","description":"List the saved views on a list that the caller can view.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_transcript_fragments":{"id":"affinity_list_transcript_fragments","name":"Affinity List Transcript Fragments","description":"Page through everything said in a meeting, segment by segment with the speaker.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"transcriptId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The transcript ID"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"hostedApiKey":"none"},"affinity_list_transcripts":{"id":"affinity_list_transcripts","name":"Affinity List Transcripts","description":"Page through meeting transcript metadata. Read one transcript to get what was actually said.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"hostedApiKey":"none"},"affinity_list_users":{"id":"affinity_list_users","name":"Affinity List Users","description":"Page through the internal users in the organization. Email addresses and roles are returned only to callers with the \\"Manage Users\\" permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"term":{"type":"string","required":false,"visibility":"user-or-llm","description":"Case-insensitive match across first name, last name, and primary email"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression over id or status, e.g. \\"status=active\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_search_companies":{"id":"affinity_search_companies","name":"Affinity Search Companies","description":"Search companies by filters, sorts, and a free-text term. Requires the \\"Export All Organizations directory\\" permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filters":{"type":"json","required":false,"visibility":"user-or-llm","description":"Filter group as {operator: \\"and\\"|\\"or\\", filters: [...]}, at most 50 leaves. Each leaf is {valueType, fieldId, operator, value}, and a leaf may itself be a nested group"},"searchTerm":{"type":"string","required":false,"visibility":"user-or-llm","description":"Free-text term matched against the searchable fields. At least 3 characters"},"searchFieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs the search term is matched against. Defaults to the searchable fields"},"sorts":{"type":"json","required":false,"visibility":"user-or-llm","description":"Sort order as [{fieldId, direction: \\"asc\\"|\\"desc\\", attributeId?}], up to 5, applied in order"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, or relationship-intelligence. Mutually exclusive with Field IDs"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"hostedApiKey":"none"},"affinity_search_files":{"id":"affinity_search_files","name":"Affinity Search Files","description":"Search files by keyword, ordered by relevance. Narrow to specific files or to one company, or leave both unset to search the whole account.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"prompt":{"type":"string","required":true,"visibility":"user-or-llm","description":"What to search for. Between 3 and 500 characters"},"ids":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict the search to these file IDs. Cannot be combined with Company ID"},"companyId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Restrict the search to one company\'s files. Cannot be combined with file IDs"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of files to return, 1-100. Defaults to 20"}},"hostedApiKey":"none"},"affinity_search_list_entries":{"id":"affinity_search_list_entries","name":"Affinity Search List Entries","description":"Search the rows of one list by filters, sorts, and a free-text term. Requires the \\"Export data from Lists\\" permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID to search"},"filters":{"type":"json","required":false,"visibility":"user-or-llm","description":"Filter group as {operator: \\"and\\"|\\"or\\", filters: [...]}, at most 50 leaves. Each leaf is {valueType, fieldId, operator, value}, and a leaf may itself be a nested group"},"searchTerm":{"type":"string","required":false,"visibility":"user-or-llm","description":"Free-text term matched against the searchable fields. At least 3 characters"},"searchFieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs the search term is matched against. Defaults to the searchable fields"},"sorts":{"type":"json","required":false,"visibility":"user-or-llm","description":"Sort order as [{fieldId, direction: \\"asc\\"|\\"desc\\", attributeId?}], up to 5, applied in order"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, list, or relationship-intelligence. Mutually exclusive with Field IDs"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"hostedApiKey":"none"},"affinity_search_notes":{"id":"affinity_search_notes","name":"Affinity Search Notes","description":"Search notes by keyword, ordered by relevance. Narrow to specific notes or to one company, or leave both unset to search the whole account.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"prompt":{"type":"string","required":true,"visibility":"user-or-llm","description":"What to search for. Between 3 and 500 characters"},"ids":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict the search to these note IDs. Cannot be combined with Company ID"},"companyId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Restrict the search to one company\'s notes. Cannot be combined with note IDs"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of notes to return, 1-100. Defaults to 20"}},"hostedApiKey":"none"},"affinity_search_persons":{"id":"affinity_search_persons","name":"Affinity Search Persons","description":"Search persons by filters, sorts, and a free-text term. Requires the \\"Export All People directory\\" permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filters":{"type":"json","required":false,"visibility":"user-or-llm","description":"Filter group as {operator: \\"and\\"|\\"or\\", filters: [...]}, at most 50 leaves. Each leaf is {valueType, fieldId, operator, value}, and a leaf may itself be a nested group"},"searchTerm":{"type":"string","required":false,"visibility":"user-or-llm","description":"Free-text term matched against the searchable fields. At least 3 characters"},"searchFieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs the search term is matched against. Defaults to the searchable fields"},"sorts":{"type":"json","required":false,"visibility":"user-or-llm","description":"Sort order as [{fieldId, direction: \\"asc\\"|\\"desc\\", attributeId?}], up to 5, applied in order"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, or relationship-intelligence. Mutually exclusive with Field IDs"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"hostedApiKey":"none"},"affinity_semantic_search":{"id":"affinity_semantic_search","name":"Affinity Semantic Search","description":"Find companies from a description in plain language — industry, technology, stage, or business model. Currently searches companies only.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"prompt":{"type":"string","required":true,"visibility":"user-or-llm","description":"What to look for, in plain language, e.g. \\"climate tech companies in our pipeline\\". Up to 500 characters"},"listIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict the search to companies on these lists, e.g. [1, 2]"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of companies to return, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_update_entity_field_value":{"id":"affinity_update_entity_field_value","name":"Affinity Update Entity Field Value","description":"Write one non-list field value on a company or person. The value type must match how the field is defined.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which entity to write the field on: companies or persons"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company or person"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The field ID to write"},"value":{"type":"json","required":true,"visibility":"user-or-llm","description":"The new value as {type, data}, where type matches the field\'s value type. Examples: {\\"type\\":\\"text\\",\\"data\\":\\"Series B\\"}, {\\"type\\":\\"number\\",\\"data\\":42}, {\\"type\\":\\"dropdown\\",\\"data\\":{\\"dropdownOptionId\\":7}}, {\\"type\\":\\"person\\",\\"data\\":{\\"id\\":123}}, {\\"type\\":\\"person-multi\\",\\"data\\":[{\\"id\\":123}]}. Pass data as null to clear the field"}},"hostedApiKey":"none"},"affinity_update_list_entry_field":{"id":"affinity_update_list_entry_field","name":"Affinity Update List Entry Field","description":"Write one field value on a list row. Requires the \\"Export data from Lists\\" permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"listEntryId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list entry ID"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The field ID to write"},"value":{"type":"json","required":true,"visibility":"user-or-llm","description":"The new value as {type, data}, where type matches the field\'s value type. Examples: {\\"type\\":\\"text\\",\\"data\\":\\"Series B\\"}, {\\"type\\":\\"number\\",\\"data\\":42}, {\\"type\\":\\"dropdown\\",\\"data\\":{\\"dropdownOptionId\\":7}}, {\\"type\\":\\"person\\",\\"data\\":{\\"id\\":123}}, {\\"type\\":\\"person-multi\\",\\"data\\":[{\\"id\\":123}]}. Pass data as null to clear the field"}},"hostedApiKey":"none"},"affinity_update_list_field_dropdown_option":{"id":"affinity_update_list_field_dropdown_option","name":"Affinity Update List Field Dropdown Option","description":"Change a dropdown option on a list field. Every field is optional — supply only what should change, and only fields the option\'s kind actually has.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown field ID on that list"},"dropdownOptionId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown option ID to update"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Replacement option label. Supply at least one field to change"},"rank":{"type":"number","required":false,"visibility":"user-or-llm","description":"Sort order. Required on a ranked-dropdown or status-dropdown option"},"color":{"type":"string","required":false,"visibility":"user-or-llm","description":"Option color: white, gray, blue, green, purple, orange, or red. Required on a ranked-dropdown or status-dropdown option"},"statusCategory":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pipeline meaning of the option: open, won, lost, or on-hold. Status-dropdown options only"},"winRate":{"type":"number","required":false,"visibility":"user-or-llm","description":"Expected win rate of the status. Status-dropdown options only"}},"hostedApiKey":"none"},"affinity_update_note":{"id":"affinity_update_note","name":"Affinity Update Note","description":"Rewrite a note\'s body or replace which records it is attached to. Each list of IDs replaces that association wholesale, an empty list clears it, and omitting one leaves it untouched. A note\'s type cannot be changed.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"noteId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note ID to update"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"Replacement note body as HTML"},"companyIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Replacement set of attached companies, e.g. [1, 2]. Send [] to detach every company; omit to leave them unchanged"},"personIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Replacement set of attached persons, e.g. [1, 2]. Send [] to detach every person; omit to leave them unchanged"},"opportunityIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Replacement set of attached opportunities, e.g. [1, 2]. Send [] to detach every opportunity; omit to leave them unchanged"}},"hostedApiKey":"none"},"agentmail_create_draft":{"id":"agentmail_create_draft","name":"Create Draft","description":"Create a new email draft in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to create the draft in"},"to":{"type":"string","required":false,"visibility":"user-or-llm","description":"Recipient email addresses (comma-separated)"},"subject":{"type":"string","required":false,"visibility":"user-or-llm","description":"Draft subject line"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Plain text draft body"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"HTML draft body"},"cc":{"type":"string","required":false,"visibility":"user-or-llm","description":"CC recipient email addresses (comma-separated)"},"bcc":{"type":"string","required":false,"visibility":"user-or-llm","description":"BCC recipient email addresses (comma-separated)"},"inReplyTo":{"type":"string","required":false,"visibility":"user-or-llm","description":"ID of message being replied to"},"sendAt":{"type":"string","required":false,"visibility":"user-or-llm","description":"ISO 8601 timestamp to schedule sending"}},"hostedApiKey":"none"},"agentmail_create_inbox":{"id":"agentmail_create_inbox","name":"Create Inbox","description":"Create a new email inbox with AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"username":{"type":"string","required":false,"visibility":"user-or-llm","description":"Username for the inbox email address"},"domain":{"type":"string","required":false,"visibility":"user-or-llm","description":"Domain for the inbox email address"},"displayName":{"type":"string","required":false,"visibility":"user-or-llm","description":"Display name for the inbox"}},"hostedApiKey":"none"},"agentmail_delete_draft":{"id":"agentmail_delete_draft","name":"Delete Draft","description":"Delete an email draft in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the draft"},"draftId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the draft to delete"}},"hostedApiKey":"none"},"agentmail_delete_inbox":{"id":"agentmail_delete_inbox","name":"Delete Inbox","description":"Delete an email inbox in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to delete"}},"hostedApiKey":"none"},"agentmail_delete_thread":{"id":"agentmail_delete_thread","name":"Delete Thread","description":"Delete an email thread in AgentMail (moves to trash, or permanently deletes if already in trash)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the thread"},"threadId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the thread to delete"},"permanent":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Force permanent deletion instead of moving to trash"}},"hostedApiKey":"none"},"agentmail_forward_message":{"id":"agentmail_forward_message","name":"Forward Message","description":"Forward an email message to new recipients in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the message"},"messageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the message to forward"},"to":{"type":"string","required":true,"visibility":"user-or-llm","description":"Recipient email addresses (comma-separated)"},"subject":{"type":"string","required":false,"visibility":"user-or-llm","description":"Override subject line"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Additional plain text to prepend"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"Additional HTML to prepend"},"cc":{"type":"string","required":false,"visibility":"user-or-llm","description":"CC recipient email addresses (comma-separated)"},"bcc":{"type":"string","required":false,"visibility":"user-or-llm","description":"BCC recipient email addresses (comma-separated)"}},"hostedApiKey":"none"},"agentmail_get_draft":{"id":"agentmail_get_draft","name":"Get Draft","description":"Get details of a specific email draft in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox the draft belongs to"},"draftId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the draft to retrieve"}},"hostedApiKey":"none"},"agentmail_get_inbox":{"id":"agentmail_get_inbox","name":"Get Inbox","description":"Get details of a specific email inbox in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to retrieve"}},"hostedApiKey":"none"},"agentmail_get_message":{"id":"agentmail_get_message","name":"Get Message","description":"Get details of a specific email message in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the message"},"messageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the message to retrieve"}},"hostedApiKey":"none"},"agentmail_get_thread":{"id":"agentmail_get_thread","name":"Get Thread","description":"Get details of a specific email thread including messages in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the thread"},"threadId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the thread to retrieve"}},"hostedApiKey":"none"},"agentmail_list_drafts":{"id":"agentmail_list_drafts","name":"List Drafts","description":"List email drafts in an inbox in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to list drafts from"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of drafts to return"},"pageToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token for next page of results"}},"hostedApiKey":"none"},"agentmail_list_inboxes":{"id":"agentmail_list_inboxes","name":"List Inboxes","description":"List all email inboxes in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of inboxes to return"},"pageToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token for next page of results"}},"hostedApiKey":"none"},"agentmail_list_messages":{"id":"agentmail_list_messages","name":"List Messages","description":"List messages in an inbox in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to list messages from"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of messages to return"},"pageToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token for next page of results"}},"hostedApiKey":"none"},"agentmail_list_threads":{"id":"agentmail_list_threads","name":"List Threads","description":"List email threads in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to list threads from"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of threads to return"},"pageToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token for next page of results"},"labels":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated labels to filter threads by"},"before":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter threads before this ISO 8601 timestamp"},"after":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter threads after this ISO 8601 timestamp"}},"hostedApiKey":"none"},"agentmail_reply_message":{"id":"agentmail_reply_message","name":"Reply to Message","description":"Reply to an existing email message in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to reply from"},"messageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the message to reply to"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Plain text reply body"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"HTML reply body"},"to":{"type":"string","required":false,"visibility":"user-or-llm","description":"Override recipient email addresses (comma-separated)"},"cc":{"type":"string","required":false,"visibility":"user-or-llm","description":"CC email addresses (comma-separated)"},"bcc":{"type":"string","required":false,"visibility":"user-or-llm","description":"BCC email addresses (comma-separated)"},"replyAll":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Reply to all recipients of the original message"}},"hostedApiKey":"none"},"agentmail_send_draft":{"id":"agentmail_send_draft","name":"Send Draft","description":"Send an existing email draft in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the draft"},"draftId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the draft to send"}},"hostedApiKey":"none"},"agentmail_send_message":{"id":"agentmail_send_message","name":"Send Message","description":"Send an email message from an AgentMail inbox","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to send from"},"to":{"type":"string","required":true,"visibility":"user-or-llm","description":"Recipient email address (comma-separated for multiple)"},"subject":{"type":"string","required":true,"visibility":"user-or-llm","description":"Email subject line"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Plain text email body"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"HTML email body"},"cc":{"type":"string","required":false,"visibility":"user-or-llm","description":"CC recipient email addresses (comma-separated)"},"bcc":{"type":"string","required":false,"visibility":"user-or-llm","description":"BCC recipient email addresses (comma-separated)"}},"hostedApiKey":"none"},"agentmail_update_draft":{"id":"agentmail_update_draft","name":"Update Draft","description":"Update an existing email draft in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the draft"},"draftId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the draft to update"},"to":{"type":"string","required":false,"visibility":"user-or-llm","description":"Recipient email addresses (comma-separated)"},"subject":{"type":"string","required":false,"visibility":"user-or-llm","description":"Draft subject line"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Plain text draft body"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"HTML draft body"},"cc":{"type":"string","required":false,"visibility":"user-or-llm","description":"CC recipient email addresses (comma-separated)"},"bcc":{"type":"string","required":false,"visibility":"user-or-llm","description":"BCC recipient email addresses (comma-separated)"},"sendAt":{"type":"string","required":false,"visibility":"user-or-llm","description":"ISO 8601 timestamp to schedule sending"}},"hostedApiKey":"none"},"agentmail_update_inbox":{"id":"agentmail_update_inbox","name":"Update Inbox","description":"Update the display name of an email inbox in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to update"},"displayName":{"type":"string","required":true,"visibility":"user-or-llm","description":"New display name for the inbox"}},"hostedApiKey":"none"},"agentmail_update_message":{"id":"agentmail_update_message","name":"Update Message","description":"Add or remove labels on an email message in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the message"},"messageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the message to update"},"addLabels":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated labels to add to the message"},"removeLabels":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated labels to remove from the message"}},"hostedApiKey":"none"},"agentmail_update_thread":{"id":"agentmail_update_thread","name":"Update Thread Labels","description":"Add or remove labels on an email thread in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the thread"},"threadId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the thread to update"},"addLabels":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated labels to add to the thread"},"removeLabels":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated labels to remove from the thread"}},"hostedApiKey":"none"},"agentphone_create_call":{"id":"agentphone_create_call","name":"Create Outbound Call","description":"Initiate an outbound voice call from an AgentPhone agent","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"agentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Agent that will handle the call"},"toNumber":{"type":"string","required":true,"visibility":"user-or-llm","description":"Phone number to call in E.164 format (e.g. +14155551234)"},"fromNumberId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Phone number ID to use as caller ID. Must belong to the agent. If omitted, the agent\'s first assigned number is used."},"initialGreeting":{"type":"string","required":false,"visibility":"user-or-llm","description":"Optional greeting spoken when the recipient answers"},"voice":{"type":"string","required":false,"visibility":"user-or-llm","description":"Voice ID override for this call (defaults to the agent\'s configured voice)"},"systemPrompt":{"type":"string","required":false,"visibility":"user-or-llm","description":"When provided, uses a built-in LLM for the conversation instead of forwarding to your webhook"}},"hostedApiKey":"none"},"agentphone_create_contact":{"id":"agentphone_create_contact","name":"Create Contact","description":"Create a new contact in AgentPhone","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"phoneNumber":{"type":"string","required":true,"visibility":"user-or-llm","description":"Phone number in E.164 format (e.g. +14155551234)"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Contact\'s full name"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"Contact\'s email address"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"Freeform notes stored on the contact"}},"hostedApiKey":"none"},"agentphone_create_number":{"id":"agentphone_create_number","name":"Create Phone Number","description":"Provision a new SMS- and voice-enabled phone number","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Two-letter country code (e.g. US, CA). Defaults to US."},"areaCode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Preferred area code (US/CA only, e.g. \\"415\\"). Best-effort — may be ignored if unavailable."},"agentId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Optionally attach the number to an agent immediately"}},"hostedApiKey":"none"},"agentphone_delete_contact":{"id":"agentphone_delete_contact","name":"Delete Contact","description":"Delete a contact by ID","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"contactId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Contact ID"}},"hostedApiKey":"none"},"agentphone_get_call":{"id":"agentphone_get_call","name":"Get Call","description":"Fetch a call and its full transcript","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"callId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the call to retrieve"}},"hostedApiKey":"none"},"agentphone_get_call_transcript":{"id":"agentphone_get_call_transcript","name":"Get Call Transcript","description":"Get the full ordered transcript for a call","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"callId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the call to retrieve the transcript for"}},"hostedApiKey":"none"},"agentphone_get_contact":{"id":"agentphone_get_contact","name":"Get Contact","description":"Fetch a single contact by ID","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"contactId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Contact ID"}},"hostedApiKey":"none"},"agentphone_get_conversation":{"id":"agentphone_get_conversation","name":"Get Conversation","description":"Get a conversation along with its recent messages","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"conversationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Conversation ID"},"messageLimit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of recent messages to include (default 50, max 100)"}},"hostedApiKey":"none"},"agentphone_get_conversation_messages":{"id":"agentphone_get_conversation_messages","name":"Get Conversation Messages","description":"Get paginated messages for a conversation","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"conversationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Conversation ID"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of messages to return (default 50, max 200)"},"before":{"type":"string","required":false,"visibility":"user-or-llm","description":"Return messages received before this ISO 8601 timestamp"},"after":{"type":"string","required":false,"visibility":"user-or-llm","description":"Return messages received after this ISO 8601 timestamp"}},"hostedApiKey":"none"},"agentphone_get_number_messages":{"id":"agentphone_get_number_messages","name":"Get Phone Number Messages","description":"Fetch messages received on a specific phone number","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"numberId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the phone number"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of messages to return (default 50, max 200)"},"before":{"type":"string","required":false,"visibility":"user-or-llm","description":"Return messages received before this ISO 8601 timestamp"},"after":{"type":"string","required":false,"visibility":"user-or-llm","description":"Return messages received after this ISO 8601 timestamp"}},"hostedApiKey":"none"},"agentphone_get_usage":{"id":"agentphone_get_usage","name":"Get Usage","description":"Retrieve current usage statistics for the AgentPhone account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"}},"hostedApiKey":"none"},"agentphone_get_usage_daily":{"id":"agentphone_get_usage_daily","name":"Get Daily Usage","description":"Get a daily breakdown of usage (messages, calls, webhooks) for the last N days","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"days":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of days to return (1-365, default 30)"}},"hostedApiKey":"none"},"agentphone_get_usage_monthly":{"id":"agentphone_get_usage_monthly","name":"Get Monthly Usage","description":"Get monthly usage aggregation (messages, calls, webhooks) for the last N months","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"months":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of months to return (1-24, default 6)"}},"hostedApiKey":"none"},"agentphone_list_calls":{"id":"agentphone_list_calls","name":"List Calls","description":"List voice calls for this AgentPhone account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to return (default 20, max 100)"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to skip (min 0)"},"status":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter by status (completed, in-progress, failed)"},"direction":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter by direction (inbound, outbound)"},"type":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter by call type (pstn, web)"},"search":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search by phone number (matches fromNumber or toNumber)"}},"hostedApiKey":"none"},"agentphone_list_contacts":{"id":"agentphone_list_contacts","name":"List Contacts","description":"List contacts for this AgentPhone account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"search":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter by name or phone number (case-insensitive contains)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to return (default 50, max 200)"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to skip (min 0)"}},"hostedApiKey":"none"},"agentphone_list_conversations":{"id":"agentphone_list_conversations","name":"List Conversations","description":"List conversations (message threads) for this AgentPhone account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to return (default 20, max 100)"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to skip (min 0)"}},"hostedApiKey":"none"},"agentphone_list_numbers":{"id":"agentphone_list_numbers","name":"List Phone Numbers","description":"List all phone numbers provisioned for this AgentPhone account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to return (default 20, max 100)"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to skip (min 0)"}},"hostedApiKey":"none"},"agentphone_react_to_message":{"id":"agentphone_react_to_message","name":"React to Message","description":"Send an iMessage tapback reaction to a message (iMessage only)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"messageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the message to react to"},"reaction":{"type":"string","required":true,"visibility":"user-or-llm","description":"Reaction type: love, like, dislike, laugh, emphasize, or question"}},"hostedApiKey":"none"},"agentphone_release_number":{"id":"agentphone_release_number","name":"Release Phone Number","description":"Release (delete) a phone number. This action is irreversible.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"numberId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the phone number to release"}},"hostedApiKey":"none"},"agentphone_send_message":{"id":"agentphone_send_message","name":"Send Message","description":"Send an outbound SMS or iMessage from an AgentPhone agent","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"agentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Agent sending the message"},"toNumber":{"type":"string","required":true,"visibility":"user-or-llm","description":"Recipient phone number in E.164 format (e.g. +14155551234)"},"body":{"type":"string","required":true,"visibility":"user-or-llm","description":"Message text to send"},"mediaUrl":{"type":"string","required":false,"visibility":"user-or-llm","description":"Optional URL of an image, video, or file to attach"},"numberId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Phone number ID to send from. If omitted, the agent\'s first assigned number is used."}},"hostedApiKey":"none"},"agentphone_update_contact":{"id":"agentphone_update_contact","name":"Update Contact","description":"Update a contact\'s fields","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"contactId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Contact ID"},"phoneNumber":{"type":"string","required":false,"visibility":"user-or-llm","description":"New phone number in E.164 format"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"New contact name"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"New email address"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"New freeform notes"}},"hostedApiKey":"none"},"agentphone_update_conversation":{"id":"agentphone_update_conversation","name":"Update Conversation","description":"Update conversation metadata (stored state). Pass null to clear existing metadata.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"conversationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Conversation ID"},"metadata":{"type":"json","required":false,"visibility":"user-or-llm","description":"Custom key-value metadata to store on the conversation. Pass null to clear existing metadata."}},"hostedApiKey":"none"},"agiloft_async_status":{"id":"agiloft_async_status","name":"Agiloft Async Status","description":"Check whether an asynchronous Agiloft call, such as a run action button, has completed.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table the asynchronous call was made against"},"callbackId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Callback ID returned by the asynchronous call, e.g. from Run Action Button"}},"hostedApiKey":"none"},"agiloft_attach_file":{"id":"agiloft_attach_file","name":"Agiloft Attach File","description":"Attach a file to a field in an Agiloft record.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to attach the file to"},"fieldName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the attachment field"},"file":{"type":"file","required":true,"visibility":"user-or-llm","description":"File to attach"},"fileName":{"type":"string","required":false,"visibility":"user-or-llm","description":"Name to assign to the file (defaults to original file name)"},"overwrite":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Replace the contents of the field instead of adding another file to it"}},"hostedApiKey":"none"},"agiloft_attachment_info":{"id":"agiloft_attachment_info","name":"Agiloft Attachment Info","description":"Get information about file attachments on a record field.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to check attachments on"},"fieldName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the attachment field to inspect"}},"hostedApiKey":"none"},"agiloft_create_record":{"id":"agiloft_create_record","name":"Agiloft Create Record","description":"Create a new record in an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"data":{"type":"string","required":true,"visibility":"user-or-llm","description":"Record field values as a JSON object (e.g., {\\"first_name\\": \\"John\\", \\"status\\": \\"Active\\"})"}},"hostedApiKey":"none"},"agiloft_delete_record":{"id":"agiloft_delete_record","name":"Agiloft Delete Record","description":"Delete a record from an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to delete"},"substituteIds":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated IDs of records that adopt the dependants of the deleted record. Read only when the delete rule is REPLACE_WITH_ANOTHER."},"deleteRule":{"type":"string","required":false,"visibility":"user-or-llm","description":"How to treat records that depend on this one: ERROR_IF_DEPENDANTS (default — fails rather than cascading), APPLY_DELETE_WHERE_POSSIBLE, DELETE_WHERE_POSSIBLE_OTHERWISE_UNLINK, APPLY_UNLINK, UNLINK_WHERE_POSSIBLE_OTHERWISE_DELETE, or REPLACE_WITH_ANOTHER"}},"hostedApiKey":"none"},"agiloft_get_choice_line_id":{"id":"agiloft_get_choice_line_id","name":"Agiloft Get Choice Line ID","description":"Resolve the internal numeric ID of a choice-list value, for use in EWSelect WHERE clauses against choice fields.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"case\\", \\"contracts\\")"},"fieldName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Choice field name (e.g., \\"priority\\", \\"status\\")"},"value":{"type":"string","required":true,"visibility":"user-or-llm","description":"Choice display value to resolve (e.g., \\"High\\", \\"Active\\")"}},"hostedApiKey":"none"},"agiloft_list_tables":{"id":"agiloft_list_tables","name":"Agiloft List Tables","description":"List the tables and fields in an Agiloft knowledge base, to discover the logical names other operations need.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":false,"visibility":"user-or-llm","description":"Logical name of a single table to describe (e.g., \\"contacts\\"). Leave empty to list every table in the knowledge base."},"includeLinkedInfo":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the source table and column behind each linked field"},"skipColumnsInfo":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Return table names only, omitting field details, for a much smaller response"}},"hostedApiKey":"none"},"agiloft_lock_record":{"id":"agiloft_lock_record","name":"Agiloft Lock Record","description":"Lock, unlock, or check the lock status of an Agiloft record.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to lock, unlock, or check"},"lockAction":{"type":"string","required":true,"visibility":"user-or-llm","description":"Action to perform: \\"lock\\", \\"unlock\\", or \\"check\\""},"force":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Unlock only: release a lock held by another user."}},"hostedApiKey":"none"},"agiloft_nlp_search":{"id":"agiloft_nlp_search","name":"Agiloft Natural Language Search","description":"Search Agiloft records by describing what you want in plain language, such as \\"active NDAs submitted last month\\".","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"nlpQuery":{"type":"string","required":true,"visibility":"user-or-llm","description":"The request in plain language, e.g. \\"Show me open, high-priority contracts\\". Structured field filters are not accepted — use Search Records for those."},"fields":{"type":"string","required":true,"visibility":"user-or-llm","description":"Comma-separated field names to return, e.g. \\"id, contract_title1, company_name\\""},"page":{"type":"string","required":false,"visibility":"user-or-llm","description":"Page number, starting from 0"},"limit":{"type":"string","required":false,"visibility":"user-or-llm","description":"Records per page"}},"hostedApiKey":"none"},"agiloft_read_record":{"id":"agiloft_read_record","name":"Agiloft Read Record","description":"Read a record by ID from an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to read"},"fields":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of field names to include in the response"}},"hostedApiKey":"none"},"agiloft_remove_attachment":{"id":"agiloft_remove_attachment","name":"Agiloft Remove Attachment","description":"Remove an attached file from a field in an Agiloft record.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record containing the attachment"},"fieldName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the attachment field"},"position":{"type":"string","required":true,"visibility":"user-or-llm","description":"Position index of the file to remove (starting from 0)"}},"hostedApiKey":"none"},"agiloft_retrieve_attachment":{"id":"agiloft_retrieve_attachment","name":"Agiloft Retrieve Attachment","description":"Download an attached file from an Agiloft record field.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record containing the attachment"},"fieldName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the attachment field"},"position":{"type":"string","required":true,"visibility":"user-or-llm","description":"Position index of the file in the field (starting from 0)"}},"hostedApiKey":"none"},"agiloft_run_action_button":{"id":"agiloft_run_action_button","name":"Agiloft Run Action Button","description":"Run an action button on an Agiloft record, such as an approval or send-for-signature step.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"case\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to run the action button on"},"actionButtonField":{"type":"string","required":true,"visibility":"user-or-llm","description":"Logical name of the field holding the action button (e.g., \\"ab_field\\")"}},"hostedApiKey":"none"},"agiloft_saved_search":{"id":"agiloft_saved_search","name":"Agiloft Saved Search","description":"List the saved searches defined for an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Logical table name to list saved searches for (e.g., \\"contract\\")"}},"hostedApiKey":"none"},"agiloft_search_records":{"id":"agiloft_search_records","name":"Agiloft Search Records","description":"Search for records in an Agiloft table using a query.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name to search in (e.g., \\"contracts\\", \\"contacts.employees\\")"},"query":{"type":"string","required":false,"visibility":"user-or-llm","description":"Ad hoc EWSearch query. Combine conditions with && (and) or || (or) and quote every value — e.g. \\"summary~=\'test\'&&priority=\'High\'\\". Required unless a saved search is given."},"search":{"type":"string","required":false,"visibility":"user-or-llm","description":"Label of a saved search defined on the table (e.g., \\"C: Status is Closed\\"). Can be combined with a query to narrow it further."},"fields":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of field names to include in the results"},"page":{"type":"string","required":false,"visibility":"user-or-llm","description":"Page number for paginated results (starting from 0)"},"limit":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum number of records to return per page. Agiloft treats 0 as \\"all records\\", so leave it unset or use a positive value to keep result sizes bounded."}},"hostedApiKey":"none"},"agiloft_select_records":{"id":"agiloft_select_records","name":"Agiloft Select Records","description":"Select record IDs matching a SQL WHERE clause from an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"where":{"type":"string","required":true,"visibility":"user-or-llm","description":"SQL WHERE clause using database column names (e.g., \\"summary like \'%new%\'\\" or \\"assigned_person=\'John Doe\'\\"). EWSelect has no page size and returns every matching ID, so append a database limit such as \\"limit 0,200\\" to bound the result."}},"hostedApiKey":"none"},"agiloft_update_record":{"id":"agiloft_update_record","name":"Agiloft Update Record","description":"Update an existing record in an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to update"},"data":{"type":"string","required":true,"visibility":"user-or-llm","description":"Updated field values as a JSON object (e.g., {\\"status\\": \\"Active\\", \\"priority\\": \\"High\\"})"}},"hostedApiKey":"none"},"agiloft_upsert_record":{"id":"agiloft_upsert_record","name":"Agiloft Upsert Record","description":"Create an Agiloft record, or update it when a record already matches the given fields.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"match":{"type":"string","required":true,"visibility":"user-or-llm","description":"Field used to find an existing record (e.g., \\"ext_id\\"). Pick something that identifies a record uniquely — if more than one record matches, Agiloft writes nothing and returns a conflict."},"async":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Queue the write instead of waiting for it. Returns a callback ID instead of a record ID; pass that to Async Status to poll the result."},"data":{"type":"string","required":true,"visibility":"user-or-llm","description":"Field values as a JSON object. On create these populate the new record; on update only the supplied fields change."}},"hostedApiKey":"none"},"ahrefs_anchors":{"id":"ahrefs_anchors","name":"Ahrefs Anchors","description":"Get the anchor text distribution for a target domain or URL\'s backlinks, showing how many links and referring domains use each anchor text.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"history":{"type":"string","required":false,"visibility":"user-or-llm","description":"Historical scope: \\"live\\" (currently live), \\"all_time\\" (default, includes lost backlinks), or \\"since:YYYY-MM-DD\\" (backlinks found since a date)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_backlinks":{"id":"ahrefs_backlinks","name":"Ahrefs Backlinks","description":"Get a list of backlinks pointing to a target domain or URL. Returns details about each backlink including source URL, anchor text, and domain rating.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"history":{"type":"string","required":false,"visibility":"user-or-llm","description":"Historical scope: \\"live\\" (currently live backlinks), \\"all_time\\" (default, includes lost backlinks), or \\"since:YYYY-MM-DD\\" (backlinks found since a date)."},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_backlinks_stats":{"id":"ahrefs_backlinks_stats","name":"Ahrefs Backlinks Stats","description":"Get backlink and referring domain totals for a target domain or URL, both currently live and across all time.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_batch_analysis":{"id":"ahrefs_batch_analysis","name":"Ahrefs Batch Analysis","description":"Get bulk SEO metrics (Domain Rating, backlinks, referring domains, organic traffic, and more) for multiple domains or URLs in a single request. Useful for comparing many competitors at once.","version":"1.0.0","params":{"targets":{"type":"string","required":true,"visibility":"user-or-llm","description":"Comma-separated list of domains or URLs to analyze. Example: \\"example.com,competitor.com\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode applied to every target: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"protocol":{"type":"string","required":false,"visibility":"user-or-llm","description":"Protocol applied to every target: \\"both\\" (default), \\"http\\", or \\"https\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for traffic data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"volumeMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search volume calculation: \\"monthly\\" or \\"average\\" (default: \\"monthly\\")"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_broken_backlinks":{"id":"ahrefs_broken_backlinks","name":"Ahrefs Broken Backlinks","description":"Get a list of broken backlinks pointing to a target domain or URL. Useful for identifying link reclamation opportunities.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_domain_rating":{"id":"ahrefs_domain_rating","name":"Ahrefs Domain Rating","description":"Get the Domain Rating (DR) and Ahrefs Rank for a target domain. Domain Rating shows the strength of a website\'s backlink profile on a scale from 0 to 100.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain to analyze (e.g., example.com)"},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date for historical data in YYYY-MM-DD format (defaults to today)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_domain_rating_history":{"id":"ahrefs_domain_rating_history","name":"Ahrefs Domain Rating History","description":"Get the historical Domain Rating (DR) trend for a target domain or URL over a date range, grouped daily, weekly, or monthly.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"dateFrom":{"type":"string","required":true,"visibility":"user-only","description":"Start date of the historical period, in YYYY-MM-DD format"},"dateTo":{"type":"string","required":false,"visibility":"user-only","description":"End date of the historical period, in YYYY-MM-DD format (defaults to today)"},"historyGrouping":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval for grouping data points: \\"daily\\", \\"weekly\\", or \\"monthly\\" (default: \\"monthly\\")"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_keyword_overview":{"id":"ahrefs_keyword_overview","name":"Ahrefs Keyword Overview","description":"Get detailed metrics for a keyword including search volume, keyword difficulty, CPC, clicks, and traffic potential.","version":"1.0.0","params":{"keyword":{"type":"string","required":true,"visibility":"user-or-llm","description":"The keyword to analyze"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for keyword data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_keywords_history":{"id":"ahrefs_keywords_history","name":"Ahrefs Keywords History","description":"Get the historical organic keyword ranking distribution for a target domain or URL over a date range: how many keywords rank in each position bucket at each point in time.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"dateFrom":{"type":"string","required":true,"visibility":"user-only","description":"Start date of the historical period, in YYYY-MM-DD format"},"dateTo":{"type":"string","required":false,"visibility":"user-only","description":"End date of the historical period, in YYYY-MM-DD format (defaults to today)"},"historyGrouping":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval for grouping data points: \\"daily\\", \\"weekly\\", or \\"monthly\\" (default: \\"monthly\\")"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for search results. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_metrics":{"id":"ahrefs_metrics","name":"Ahrefs Metrics","description":"Get a one-call organic and paid search overview for a target domain or URL: organic traffic, organic keywords, paid traffic, paid keywords, and estimated traffic cost.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for traffic data. Example: \\"us\\", \\"gb\\", \\"de\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_metrics_history":{"id":"ahrefs_metrics_history","name":"Ahrefs Metrics History","description":"Get the historical organic and paid traffic trend for a target domain or URL over a date range: organic traffic/cost and paid traffic/cost at each point in time.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"dateFrom":{"type":"string","required":true,"visibility":"user-only","description":"Start date of the historical period, in YYYY-MM-DD format"},"dateTo":{"type":"string","required":false,"visibility":"user-only","description":"End date of the historical period, in YYYY-MM-DD format (defaults to today)"},"volumeMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search volume calculation: \\"monthly\\" or \\"average\\" (default: \\"monthly\\")"},"historyGrouping":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval for grouping data points: \\"daily\\", \\"weekly\\", or \\"monthly\\" (default: \\"monthly\\")"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for traffic data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_organic_competitors":{"id":"ahrefs_organic_competitors","name":"Ahrefs Organic Competitors","description":"Get domains that compete with a target domain or URL for the same organic keywords, ranked by keyword overlap.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for search results. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_organic_keywords":{"id":"ahrefs_organic_keywords","name":"Ahrefs Organic Keywords","description":"Get organic keywords that a target domain or URL ranks for in Google search results. Returns keyword details including search volume, ranking position, and estimated traffic.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for search results. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_paid_pages":{"id":"ahrefs_paid_pages","name":"Ahrefs Paid Pages","description":"Get a target domain\'s pages that receive paid search traffic, sorted by estimated paid traffic. Returns page URLs with their paid traffic, keyword counts, and estimated spend.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for traffic data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_rank_tracker_competitors_overview":{"id":"ahrefs_rank_tracker_competitors_overview","name":"Ahrefs Rank Tracker Competitors Overview","description":"Get competitor rankings for the keywords tracked in an Ahrefs Rank Tracker project: each tracked keyword\'s volume and difficulty alongside every competitor\'s position, traffic, and traffic value. This endpoint is free and does not consume API units.","version":"1.0.0","params":{"projectId":{"type":"number","required":true,"visibility":"user-or-llm","description":"The Rank Tracker project ID (found in the project URL in Ahrefs)"},"date":{"type":"string","required":true,"visibility":"user-only","description":"Date to report rankings for, in YYYY-MM-DD format"},"device":{"type":"string","required":true,"visibility":"user-or-llm","description":"Rankings device type: \\"desktop\\" or \\"mobile\\""},"dateCompared":{"type":"string","required":false,"visibility":"user-only","description":"Comparison date in YYYY-MM-DD format, to compute position/traffic deltas"},"volumeMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search volume calculation: \\"monthly\\" or \\"average\\" (default: \\"monthly\\")"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_rank_tracker_competitors_stats":{"id":"ahrefs_rank_tracker_competitors_stats","name":"Ahrefs Rank Tracker Competitors Stats","description":"Get aggregate competitor stats for an Ahrefs Rank Tracker project: each competitor\'s traffic, traffic value, average position, and share of voice across all tracked keywords. This endpoint is free and does not consume API units.","version":"1.0.0","params":{"projectId":{"type":"number","required":true,"visibility":"user-or-llm","description":"The Rank Tracker project ID (found in the project URL in Ahrefs)"},"date":{"type":"string","required":true,"visibility":"user-only","description":"Date to report metrics for, in YYYY-MM-DD format"},"device":{"type":"string","required":true,"visibility":"user-or-llm","description":"Rankings device type: \\"desktop\\" or \\"mobile\\""},"volumeMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search volume calculation: \\"monthly\\" or \\"average\\" (default: \\"monthly\\")"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_rank_tracker_overview":{"id":"ahrefs_rank_tracker_overview","name":"Ahrefs Rank Tracker Overview","description":"Get ranking overview metrics for the keywords tracked in an Ahrefs Rank Tracker project: position, search volume, keyword difficulty, and estimated traffic. This endpoint is free and does not consume API units.","version":"1.0.0","params":{"projectId":{"type":"number","required":true,"visibility":"user-or-llm","description":"The Rank Tracker project ID (found in the project URL in Ahrefs)"},"date":{"type":"string","required":true,"visibility":"user-only","description":"Date to report rankings for, in YYYY-MM-DD format"},"device":{"type":"string","required":true,"visibility":"user-or-llm","description":"Rankings device type: \\"desktop\\" or \\"mobile\\""},"dateCompared":{"type":"string","required":false,"visibility":"user-only","description":"Comparison date in YYYY-MM-DD format, to compute position/traffic deltas"},"volumeMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search volume calculation: \\"monthly\\" or \\"average\\" (default: \\"monthly\\")"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_rank_tracker_serp_overview":{"id":"ahrefs_rank_tracker_serp_overview","name":"Ahrefs Rank Tracker SERP Overview","description":"Get the full SERP (search engine results page) for a keyword tracked in an Ahrefs Rank Tracker project, including every ranking URL with its position, title, and authority metrics. This endpoint is free and does not consume API units.","version":"1.0.0","params":{"projectId":{"type":"number","required":true,"visibility":"user-or-llm","description":"The Rank Tracker project ID (found in the project URL in Ahrefs)"},"keyword":{"type":"string","required":true,"visibility":"user-or-llm","description":"The tracked keyword to retrieve SERP data for"},"country":{"type":"string","required":true,"visibility":"user-or-llm","description":"Country code for the tracked keyword. Example: \\"us\\", \\"gb\\", \\"de\\""},"device":{"type":"string","required":true,"visibility":"user-or-llm","description":"Rankings device type: \\"desktop\\" or \\"mobile\\""},"topPositions":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of top organic positions to return (defaults to all available)"},"date":{"type":"string","required":false,"visibility":"user-only","description":"Timestamp to return the last available SERP Overview at, in YYYY-MM-DDThh:mm:ss format"},"locationId":{"type":"number","required":false,"visibility":"user-or-llm","description":"Location ID of the tracked keyword, if tracked at a specific location"},"languageCode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Language code of the tracked keyword"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_refdomains_history":{"id":"ahrefs_refdomains_history","name":"Ahrefs Referring Domains History","description":"Get the historical referring domains trend for a target domain or URL over a date range, grouped daily, weekly, or monthly.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"dateFrom":{"type":"string","required":true,"visibility":"user-only","description":"Start date of the historical period, in YYYY-MM-DD format"},"dateTo":{"type":"string","required":false,"visibility":"user-only","description":"End date of the historical period, in YYYY-MM-DD format (defaults to today)"},"historyGrouping":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval for grouping data points: \\"daily\\", \\"weekly\\", or \\"monthly\\" (default: \\"monthly\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_referring_domains":{"id":"ahrefs_referring_domains","name":"Ahrefs Referring Domains","description":"Get a list of domains that link to a target domain or URL. Returns unique referring domains with their domain rating, backlink counts, and discovery dates.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"history":{"type":"string","required":false,"visibility":"user-or-llm","description":"Historical scope: \\"live\\" (currently live), \\"all_time\\" (default, includes lost domains), or \\"since:YYYY-MM-DD\\" (domains found since a date)."},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_related_terms":{"id":"ahrefs_related_terms","name":"Ahrefs Related Terms","description":"Get keyword ideas related to a seed keyword: terms the same top-ranking pages also rank for (\\"also rank for\\") or also discuss (\\"also talk about\\"), with volume, difficulty, and CPC.","version":"1.0.0","params":{"keyword":{"type":"string","required":true,"visibility":"user-or-llm","description":"The seed keyword to find related terms for"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for keyword data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"terms":{"type":"string","required":false,"visibility":"user-or-llm","description":"Type of related keywords to return: \\"also_rank_for\\", \\"also_talk_about\\", or \\"all\\" (default: \\"all\\")"},"viewFor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Whether to derive related terms from the top 10 or top 100 ranking pages (default: \\"top_10\\")"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_site_audit_page_explorer":{"id":"ahrefs_site_audit_page_explorer","name":"Ahrefs Site Audit Page Explorer","description":"Get crawled pages from an Ahrefs Site Audit project with health and SEO metrics: HTTP status, title, link counts, backlinks, indexability, and traffic. Optionally filter to pages affected by a specific issue.","version":"1.0.0","params":{"projectId":{"type":"number","required":true,"visibility":"user-or-llm","description":"The Site Audit project ID (found in the project URL in Ahrefs)"},"date":{"type":"string","required":false,"visibility":"user-only","description":"Crawl date in YYYY-MM-DDThh:mm:ss format (defaults to the most recent crawl)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to skip, for pagination"},"issueId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Only return pages affected by this issue ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"ahrefs_top_pages":{"id":"ahrefs_top_pages","name":"Ahrefs Top Pages","description":"Get the top pages of a target domain sorted by organic traffic. Returns page URLs with their traffic, keyword counts, and estimated traffic value.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain to analyze. Example: \\"example.com\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for traffic data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"hostedApiKey":"none"},"airtable_create_records":{"id":"airtable_create_records","name":"Airtable Create Records","description":"Write new records to an Airtable table","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"records":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of records to create, each with a `fields` object"},"typecast":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"When true, Airtable automatically converts string values to the field type"}},"oauth":{"required":true,"provider":"airtable"},"hostedApiKey":"none"},"airtable_delete_records":{"id":"airtable_delete_records","name":"Airtable Delete Records","description":"Delete one or more records from an Airtable table by ID","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"recordIds":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of record IDs to delete (each starts with \\"rec\\", e.g., [\\"recXXXXXXXXXXXXXX\\"]). Pass a single-element array to delete one record."}},"oauth":{"required":true,"provider":"airtable"},"hostedApiKey":"none"},"airtable_get_base_schema":{"id":"airtable_get_base_schema","name":"Airtable Get Base Schema","description":"Get the schema of all tables, fields, and views in an Airtable base","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"}},"oauth":{"required":true,"provider":"airtable"},"hostedApiKey":"none"},"airtable_get_record":{"id":"airtable_get_record","name":"Airtable Get Record","description":"Retrieve a single record from an Airtable table by its ID","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Record ID to retrieve (starts with \\"rec\\", e.g., \\"recXXXXXXXXXXXXXX\\")"}},"oauth":{"required":true,"provider":"airtable"},"hostedApiKey":"none"},"airtable_list_bases":{"id":"airtable_list_bases","name":"Airtable List Bases","description":"List all bases the authenticated user has access to","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"offset":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination offset for retrieving additional bases"}},"oauth":{"required":true,"provider":"airtable"},"hostedApiKey":"none"},"airtable_list_records":{"id":"airtable_list_records","name":"Airtable List Records","description":"Read records from an Airtable table","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"maxRecords":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of records to return (default: all records)"},"filterFormula":{"type":"string","required":false,"visibility":"user-or-llm","description":"Formula to filter records (e.g., \\"({Field Name} = \'Value\')\\")"}},"oauth":{"required":true,"provider":"airtable"},"hostedApiKey":"none"},"airtable_list_tables":{"id":"airtable_list_tables","name":"Airtable List Tables","description":"List all tables and their schema in an Airtable base","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"}},"oauth":{"required":true,"provider":"airtable"},"hostedApiKey":"none"},"airtable_update_multiple_records":{"id":"airtable_update_multiple_records","name":"Airtable Update Multiple Records","description":"Update multiple existing records in an Airtable table","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"records":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of records to update, each with an `id` and a `fields` object"},"typecast":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"When true, Airtable automatically converts string values to the field type"}},"oauth":{"required":true,"provider":"airtable"},"hostedApiKey":"none"},"airtable_update_record":{"id":"airtable_update_record","name":"Airtable Update Record","description":"Update an existing record in an Airtable table by ID","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Record ID to update (starts with \\"rec\\", e.g., \\"recXXXXXXXXXXXXXX\\")"},"fields":{"type":"json","required":true,"visibility":"user-or-llm","description":"An object containing the field names and their new values"},"typecast":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"When true, Airtable automatically converts string values to the field type"}},"oauth":{"required":true,"provider":"airtable"},"hostedApiKey":"none"},"airtable_upsert_records":{"id":"airtable_upsert_records","name":"Airtable Upsert Records","description":"Update existing records or create new ones in an Airtable table, matching on the specified merge fields","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"records":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of records to upsert, each with a `fields` object"},"fieldsToMergeOn":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of field names used to match existing records (max 3). A record is updated when all merge fields match, otherwise it is created. Example: [\\"Name\\"]"},"typecast":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"When true, Airtable automatically converts string values to the field type"}},"oauth":{"required":true,"provider":"airtable"},"hostedApiKey":"none"},"airweave_search":{"id":"airweave_search","name":"Airweave Search","description":"Search your synced data collections using Airweave. Supports semantic search with hybrid, neural, or keyword retrieval strategies. Optionally generate AI-powered answers from search results.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Airweave API Key for authentication"},"collectionId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The readable ID of the collection to search"},"query":{"type":"string","required":true,"visibility":"user-or-llm","description":"The search query text"},"limit":{"type":"number","required":false,"visibility":"user-only","description":"Maximum number of results to return (default: 100)"},"retrievalStrategy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Retrieval strategy: hybrid (default), neural, or keyword"},"expandQuery":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Generate query variations to improve recall"},"rerank":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Reorder results for improved relevance using LLM"},"generateAnswer":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Generate a natural-language answer to the query"}},"hostedApiKey":"none"},"algolia_add_record":{"id":"algolia_add_record","name":"Algolia Add Record","description":"Add or replace a record in an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"objectID":{"type":"string","required":false,"visibility":"user-or-llm","description":"Object ID for the record (auto-generated if not provided)"},"record":{"type":"json","required":true,"visibility":"user-or-llm","description":"JSON object representing the record to add"}},"hostedApiKey":"none"},"algolia_batch_operations":{"id":"algolia_batch_operations","name":"Algolia Batch Operations","description":"Perform batch add, update, partial update, or delete operations on records in an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"requests":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of batch operations. Each item has \\"action\\" (addObject, updateObject, partialUpdateObject, partialUpdateObjectNoCreate, deleteObject, delete, clear) and \\"body\\" (the record data; must include objectID for update/delete; use an empty object {} for the index-level delete/clear actions)"}},"hostedApiKey":"none"},"algolia_browse_records":{"id":"algolia_browse_records","name":"Algolia Browse Records","description":"Browse and iterate over all records in an Algolia index using cursor pagination","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key (must have browse ACL)"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index to browse"},"query":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search query to filter browsed records"},"filters":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter string to narrow down results"},"attributesToRetrieve":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of attributes to retrieve"},"hitsPerPage":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of hits per page (default: 1000, max: 1000)"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous browse response for pagination"},"aroundLatLng":{"type":"string","required":false,"visibility":"user-or-llm","description":"Coordinates for geo-search (e.g., \\"40.71,-74.01\\")"},"aroundRadius":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum radius in meters for geo-search, or \\"all\\" for unlimited"},"insideBoundingBox":{"type":"json","required":false,"visibility":"user-or-llm","description":"Bounding box coordinates as [[lat1, lng1, lat2, lng2]] for geo-search"},"insidePolygon":{"type":"json","required":false,"visibility":"user-or-llm","description":"Polygon coordinates as [[lat1, lng1, lat2, lng2, lat3, lng3, ...]] for geo-search"}},"hostedApiKey":"none"},"algolia_clear_records":{"id":"algolia_clear_records","name":"Algolia Clear Records","description":"Clear all records from an Algolia index while keeping settings, synonyms, and rules","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key (must have deleteIndex ACL)"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index to clear"}},"hostedApiKey":"none"},"algolia_copy_move_index":{"id":"algolia_copy_move_index","name":"Algolia Copy/Move Index","description":"Copy or move an Algolia index to a new destination","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the source index"},"operation":{"type":"string","required":true,"visibility":"user-or-llm","description":"Operation to perform: \\"copy\\" or \\"move\\""},"destination":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the destination index"},"scope":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of scopes to copy (only for \\"copy\\" operation): [\\"settings\\", \\"synonyms\\", \\"rules\\"]. Omit to copy everything including records."}},"hostedApiKey":"none"},"algolia_delete_by_filter":{"id":"algolia_delete_by_filter","name":"Algolia Delete By Filter","description":"Delete all records matching a filter from an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key (must have deleteIndex ACL)"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"filters":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter expression to match records for deletion (e.g., \\"category:outdated\\")"},"facetFilters":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of facet filters (e.g., [\\"brand:Acme\\"])"},"numericFilters":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of numeric filters (e.g., [\\"price > 100\\"])"},"tagFilters":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of tag filters using the _tags attribute (e.g., [\\"published\\"])"},"aroundLatLng":{"type":"string","required":false,"visibility":"user-or-llm","description":"Coordinates for geo-search filter (e.g., \\"40.71,-74.01\\")"},"aroundRadius":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum radius in meters for geo-search, or \\"all\\" for unlimited"},"insideBoundingBox":{"type":"json","required":false,"visibility":"user-or-llm","description":"Bounding box coordinates as [[lat1, lng1, lat2, lng2]] for geo-search filter"},"insidePolygon":{"type":"json","required":false,"visibility":"user-or-llm","description":"Polygon coordinates as [[lat1, lng1, lat2, lng2, lat3, lng3, ...]] for geo-search filter"}},"hostedApiKey":"none"},"algolia_delete_index":{"id":"algolia_delete_index","name":"Algolia Delete Index","description":"Delete an entire Algolia index and all its records","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key (must have deleteIndex ACL)"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index to delete"}},"hostedApiKey":"none"},"algolia_delete_record":{"id":"algolia_delete_record","name":"Algolia Delete Record","description":"Delete a record by objectID from an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"objectID":{"type":"string","required":true,"visibility":"user-or-llm","description":"The objectID of the record to delete"}},"hostedApiKey":"none"},"algolia_get_record":{"id":"algolia_get_record","name":"Algolia Get Record","description":"Get a record by objectID from an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"objectID":{"type":"string","required":true,"visibility":"user-or-llm","description":"The objectID of the record to retrieve"},"attributesToRetrieve":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of attributes to retrieve"}},"hostedApiKey":"none"},"algolia_get_records":{"id":"algolia_get_records","name":"Algolia Get Records","description":"Retrieve multiple records by objectID from one or more Algolia indices","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Default index name for all requests"},"requests":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of objects specifying records to retrieve. Each must have \\"objectID\\" and optionally \\"indexName\\" and \\"attributesToRetrieve\\"."}},"hostedApiKey":"none"},"algolia_get_settings":{"id":"algolia_get_settings","name":"Algolia Get Settings","description":"Retrieve the settings of an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"}},"hostedApiKey":"none"},"algolia_get_task_status":{"id":"algolia_get_task_status","name":"Algolia Get Task Status","description":"Check whether an Algolia indexing task has finished publishing","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index the task ran against"},"taskID":{"type":"number","required":true,"visibility":"user-or-llm","description":"The taskID returned by a previous write operation"}},"hostedApiKey":"none"},"algolia_list_indices":{"id":"algolia_list_indices","name":"Algolia List Indices","description":"List all indices in an Algolia application","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for paginating indices (default: not paginated)"},"hitsPerPage":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of indices per page (default: 100)"}},"hostedApiKey":"none"},"algolia_partial_update_record":{"id":"algolia_partial_update_record","name":"Algolia Partial Update Record","description":"Partially update a record in an Algolia index without replacing it entirely","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"objectID":{"type":"string","required":true,"visibility":"user-or-llm","description":"The objectID of the record to update"},"attributes":{"type":"json","required":true,"visibility":"user-or-llm","description":"JSON object with attributes to update. Supports built-in operations like {\\"stock\\": {\\"_operation\\": \\"Decrement\\", \\"value\\": 1}}"},"createIfNotExists":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Whether to create the record if it does not exist (default: true)"}},"hostedApiKey":"none"},"algolia_search":{"id":"algolia_search","name":"Algolia Search","description":"Search an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index to search"},"query":{"type":"string","required":true,"visibility":"user-or-llm","description":"Search query text"},"hitsPerPage":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of hits per page (default: 20)"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number to retrieve (default: 0)"},"filters":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter string (e.g., \\"category:electronics AND price < 100\\")"},"attributesToRetrieve":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of attributes to retrieve"},"facets":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of facet attribute names to retrieve counts for (use \\"*\\" for all)"},"getRankingInfo":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Whether to include detailed ranking information in each hit"},"aroundLatLng":{"type":"string","required":false,"visibility":"user-or-llm","description":"Coordinates for geo-search (e.g., \\"40.71,-74.01\\")"},"aroundRadius":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum radius in meters for geo-search, or \\"all\\" for unlimited"},"insideBoundingBox":{"type":"json","required":false,"visibility":"user-or-llm","description":"Bounding box coordinates as [[lat1, lng1, lat2, lng2]] for geo-search"},"insidePolygon":{"type":"json","required":false,"visibility":"user-or-llm","description":"Polygon coordinates as [[lat1, lng1, lat2, lng2, lat3, lng3, ...]] for geo-search"}},"hostedApiKey":"none"},"algolia_update_settings":{"id":"algolia_update_settings","name":"Algolia Update Settings","description":"Update the settings of an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key (must have editSettings ACL)"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"settings":{"type":"json","required":true,"visibility":"user-or-llm","description":"JSON object with settings to update (e.g., {\\"searchableAttributes\\": [\\"name\\", \\"description\\"], \\"customRanking\\": [\\"desc(popularity)\\"]})"},"forwardToReplicas":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Whether to apply changes to replica indices (default: false)"}},"hostedApiKey":"none"},"amplitude_event_segmentation":{"id":"amplitude_event_segmentation","name":"Amplitude Event Segmentation","description":"Query event analytics data with segmentation. Get event counts, uniques, averages, and more.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"eventType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Event type name to analyze"},"start":{"type":"string","required":true,"visibility":"user-or-llm","description":"Start date in YYYYMMDD format"},"end":{"type":"string","required":true,"visibility":"user-or-llm","description":"End date in YYYYMMDD format"},"metric":{"type":"string","required":false,"visibility":"user-or-llm","description":"Metric type: uniques, totals, pct_dau, average, histogram, sums, value_avg, or formula (default: uniques)"},"interval":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval: 1 (daily), 7 (weekly), or 30 (monthly)"},"groupBy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Property name to group by (prefix custom user properties with \\"gp:\\")"},"groupBy2":{"type":"string","required":false,"visibility":"user-or-llm","description":"Second property name to group by (prefix custom user properties with \\"gp:\\")"},"limit":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum number of group-by values (max 1000)"},"filters":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON array of filter objects applied to the event, e.g. [{\\"subprop_type\\":\\"event\\",\\"subprop_key\\":\\"city\\",\\"subprop_op\\":\\"is\\",\\"subprop_value\\":[\\"San Francisco\\"]}]"},"formula":{"type":"string","required":false,"visibility":"user-or-llm","description":"Required when metric is \\"formula\\", e.g. \\"UNIQUES(A)/UNIQUES(B)\\""},"segment":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON segment definition(s) applied to the query"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_funnels":{"id":"amplitude_funnels","name":"Amplitude Funnels","description":"Analyze conversion rates and drop-off between a sequence of events.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"events":{"type":"string","required":true,"visibility":"user-or-llm","description":"JSON array of event objects, one per funnel step in order, e.g. [{\\"event_type\\":\\"signup\\"},{\\"event_type\\":\\"purchase\\"}]"},"start":{"type":"string","required":true,"visibility":"user-or-llm","description":"Start date in YYYYMMDD format"},"end":{"type":"string","required":true,"visibility":"user-or-llm","description":"End date in YYYYMMDD format"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Funnel ordering: \\"ordered\\", \\"unordered\\", or \\"sequential\\" (default: ordered)"},"userType":{"type":"string","required":false,"visibility":"user-or-llm","description":"User type: \\"new\\" or \\"active\\" (default: active)"},"interval":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval: -300000 (real-time), -3600000 (hourly), 1 (daily), 7 (weekly), or 30 (monthly)"},"conversionWindowSeconds":{"type":"string","required":false,"visibility":"user-or-llm","description":"Conversion window in seconds (default: 2592000, i.e. 30 days)"},"groupBy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Property to group by (limit: one; prefix custom properties with \\"gp:\\")"},"limit":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum number of group-by values (default: 100, max: 1000)"},"segment":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON segment definition(s) applied to the query"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_get_active_users":{"id":"amplitude_get_active_users","name":"Amplitude Get Active Users","description":"Get active or new user counts over a date range from the Dashboard REST API.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"start":{"type":"string","required":true,"visibility":"user-or-llm","description":"Start date in YYYYMMDD format"},"end":{"type":"string","required":true,"visibility":"user-or-llm","description":"End date in YYYYMMDD format"},"metric":{"type":"string","required":false,"visibility":"user-or-llm","description":"Metric type: \\"active\\" or \\"new\\" (default: active)"},"interval":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval: 1 (daily), 7 (weekly), or 30 (monthly)"},"groupBy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Property name to group by"},"segment":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON segment definition(s) applied to the query"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_get_revenue":{"id":"amplitude_get_revenue","name":"Amplitude Get Revenue","description":"Get revenue LTV data including ARPU, ARPPU, total revenue, and paying user counts.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"start":{"type":"string","required":true,"visibility":"user-or-llm","description":"Start date in YYYYMMDD format"},"end":{"type":"string","required":true,"visibility":"user-or-llm","description":"End date in YYYYMMDD format"},"metric":{"type":"string","required":false,"visibility":"user-or-llm","description":"Metric: 0 (ARPU), 1 (ARPPU), 2 (Total Revenue), 3 (Paying Users)"},"interval":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval: 1 (daily), 7 (weekly), or 30 (monthly)"},"groupBy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Property name to group by (limit: one)"},"segment":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON segment definition(s) applied to the query"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_group_identify":{"id":"amplitude_group_identify","name":"Amplitude Group Identify","description":"Set group-level properties in Amplitude. Supports $set, $setOnce, $add, $append, $unset operations.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"groupType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Group classification (e.g., \\"company\\", \\"org_id\\")"},"groupValue":{"type":"string","required":true,"visibility":"user-or-llm","description":"Specific group identifier (e.g., \\"Acme Corp\\")"},"groupProperties":{"type":"string","required":true,"visibility":"user-or-llm","description":"JSON object of group properties. Use operations like $set, $setOnce, $add, $append, $unset."},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_identify_user":{"id":"amplitude_identify_user","name":"Amplitude Identify User","description":"Set user properties in Amplitude using the Identify API. Supports $set, $setOnce, $add, $append, $unset operations.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"userId":{"type":"string","required":false,"visibility":"user-or-llm","description":"User ID (required if no device_id)"},"deviceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Device ID (required if no user_id)"},"userProperties":{"type":"string","required":true,"visibility":"user-or-llm","description":"JSON object of user properties. Use operations like $set, $setOnce, $add, $append, $unset."},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_list_events":{"id":"amplitude_list_events","name":"Amplitude List Events","description":"List all event types in the Amplitude project with their weekly totals and unique counts.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_realtime_active_users":{"id":"amplitude_realtime_active_users","name":"Amplitude Real-time Active Users","description":"Get real-time active user counts at 5-minute granularity for the last 2 days.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_retention":{"id":"amplitude_retention","name":"Amplitude Retention","description":"Measure how many users return to perform an action after a starting action.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"startEvent":{"type":"string","required":true,"visibility":"user-or-llm","description":"JSON starting event object, e.g. {\\"event_type\\":\\"_new\\"} or {\\"event_type\\":\\"_active\\"}"},"returnEvent":{"type":"string","required":true,"visibility":"user-or-llm","description":"JSON returning event object, e.g. {\\"event_type\\":\\"_all\\"} or {\\"event_type\\":\\"_active\\"}"},"start":{"type":"string","required":true,"visibility":"user-or-llm","description":"Start date in YYYYMMDD format"},"end":{"type":"string","required":true,"visibility":"user-or-llm","description":"End date in YYYYMMDD format"},"retentionMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Retention type: \\"bracket\\", \\"rolling\\", or \\"n-day\\" (default: n-day)"},"retentionBrackets":{"type":"string","required":false,"visibility":"user-or-llm","description":"Required when Retention Mode is \\"bracket\\". Day ranges, e.g. [[0,4]]"},"interval":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval: 1 (daily), 7 (weekly), or 30 (monthly)"},"groupBy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Property to group by (limit: one; prefix custom properties with \\"gp:\\")"},"segment":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON segment definition(s) applied to the query"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_send_event":{"id":"amplitude_send_event","name":"Amplitude Send Event","description":"Track an event in Amplitude using the HTTP V2 API.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"userId":{"type":"string","required":false,"visibility":"user-or-llm","description":"User ID (required if no device_id)"},"deviceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Device ID (required if no user_id)"},"eventType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the event (e.g., \\"page_view\\", \\"purchase\\")"},"eventProperties":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON object of custom event properties"},"userProperties":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON object of user properties to set (supports $set, $setOnce, $add, $append, $unset)"},"time":{"type":"string","required":false,"visibility":"user-or-llm","description":"Event timestamp in milliseconds since epoch"},"sessionId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Session start time in milliseconds since epoch"},"insertId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Unique ID for deduplication (within 7-day window)"},"appVersion":{"type":"string","required":false,"visibility":"user-or-llm","description":"Application version string"},"platform":{"type":"string","required":false,"visibility":"user-or-llm","description":"Platform (e.g., \\"Web\\", \\"iOS\\", \\"Android\\")"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Two-letter country code"},"language":{"type":"string","required":false,"visibility":"user-or-llm","description":"Language code (e.g., \\"en\\")"},"ip":{"type":"string","required":false,"visibility":"user-or-llm","description":"IP address for geo-location"},"price":{"type":"string","required":false,"visibility":"user-or-llm","description":"Price of the item purchased"},"quantity":{"type":"string","required":false,"visibility":"user-or-llm","description":"Quantity of items purchased"},"revenue":{"type":"string","required":false,"visibility":"user-or-llm","description":"Revenue amount"},"productId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Product identifier"},"revenueType":{"type":"string","required":false,"visibility":"user-or-llm","description":"Revenue type (e.g., \\"purchase\\", \\"refund\\")"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_user_activity":{"id":"amplitude_user_activity","name":"Amplitude User Activity","description":"Get the event stream for a specific user by their Amplitude ID.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"amplitudeId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Amplitude internal user ID"},"offset":{"type":"string","required":false,"visibility":"user-or-llm","description":"Offset for pagination (default 0)"},"limit":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum number of events to return (default 1000, max 1000)"},"direction":{"type":"string","required":false,"visibility":"user-or-llm","description":"Sort direction: \\"latest\\" or \\"earliest\\" (default: latest)"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_user_profile":{"id":"amplitude_user_profile","name":"Amplitude User Profile","description":"Get a user profile including properties, cohort memberships, and computed properties. Not available for EU data-residency projects.","version":"1.0.0","params":{"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"userId":{"type":"string","required":false,"visibility":"user-or-llm","description":"External user ID (required if no device_id)"},"deviceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Device ID (required if no user_id)"},"getAmpProps":{"type":"string","required":false,"visibility":"user-or-llm","description":"Include Amplitude user properties (true/false, default: false)"},"getCohortIds":{"type":"string","required":false,"visibility":"user-or-llm","description":"Include cohort IDs the user belongs to (true/false, default: false)"},"getComputations":{"type":"string","required":false,"visibility":"user-or-llm","description":"Include computed user properties (true/false, default: false)"}},"hostedApiKey":"none"},"amplitude_user_search":{"id":"amplitude_user_search","name":"Amplitude User Search","description":"Search for a user by User ID, Device ID, or Amplitude ID using the Dashboard REST API.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"user":{"type":"string","required":true,"visibility":"user-or-llm","description":"User ID, Device ID, or Amplitude ID to search for"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"apify_get_dataset_items":{"id":"apify_get_dataset_items","name":"APIFY Get Dataset Items","description":"Retrieve items stored in an APIFY dataset","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"APIFY API token from console.apify.com/account#/integrations"},"datasetId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Dataset ID to read items from. Example: \\"9RnD3Pql2vGZkc5H5\\""},"itemLimit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Max items to return (1-250000). Default: all items. Example: 500"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to skip at the start. Default: 0"},"fields":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of fields to include. Example: \\"title,url,price\\""}},"hostedApiKey":"none"},"apify_get_run":{"id":"apify_get_run","name":"APIFY Get Run","description":"Get the status and details of an APIFY actor run","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"APIFY API token from console.apify.com/account#/integrations"},"runId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Actor run ID to fetch. Example: \\"HG7ML7M8z78YcAPEB\\""}},"hostedApiKey":"none"},"apify_run_actor_async":{"id":"apify_run_actor_async","name":"APIFY Run Actor (Async)","description":"Run an APIFY actor asynchronously with polling for long-running tasks","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"APIFY API token from console.apify.com/account#/integrations"},"actorId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Actor ID or username/actor-name. Examples: \\"apify/web-scraper\\", \\"janedoe/my-actor\\", \\"moJRLRc85AitArpNN\\""},"input":{"type":"string","required":false,"visibility":"user-or-llm","description":"Actor input as JSON string. Example: {\\"startUrls\\": [{\\"url\\": \\"https://example.com\\"}], \\"maxPages\\": 10}"},"waitForFinish":{"type":"number","required":false,"visibility":"user-or-llm","description":"Initial wait time in seconds (0-60) before polling starts. Example: 30"},"itemLimit":{"type":"number","required":false,"default":100,"visibility":"user-or-llm","description":"Max dataset items to fetch (1-250000). Default: 100. Example: 500"},"memory":{"type":"number","required":false,"visibility":"user-or-llm","description":"Memory in megabytes allocated for the actor run (128-32768). Example: 1024 for 1GB, 2048 for 2GB"},"timeout":{"type":"number","required":false,"visibility":"user-or-llm","description":"Timeout in seconds for the actor run. Example: 300 for 5 minutes, 3600 for 1 hour"},"build":{"type":"string","required":false,"visibility":"user-or-llm","description":"Actor build to run. Examples: \\"latest\\", \\"beta\\", \\"1.2.3\\", \\"build-tag-name\\""}},"hostedApiKey":"none"},"apify_run_actor_sync":{"id":"apify_run_actor_sync","name":"APIFY Run Actor (Sync)","description":"Run an APIFY actor synchronously and get results (max 5 minutes)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"APIFY API token from console.apify.com/account#/integrations"},"actorId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Actor ID or username/actor-name. Examples: \\"apify/web-scraper\\", \\"janedoe/my-actor\\", \\"moJRLRc85AitArpNN\\""},"input":{"type":"string","required":false,"visibility":"user-or-llm","description":"Actor input as JSON string. Example: {\\"startUrls\\": [{\\"url\\": \\"https://example.com\\"}], \\"maxPages\\": 10}"},"memory":{"type":"number","required":false,"visibility":"user-or-llm","description":"Memory in megabytes allocated for the actor run (128-32768). Example: 1024 for 1GB, 2048 for 2GB"},"timeout":{"type":"number","required":false,"visibility":"user-or-llm","description":"Timeout in seconds for the actor run. Example: 300 for 5 minutes, 3600 for 1 hour"},"build":{"type":"string","required":false,"visibility":"user-or-llm","description":"Actor build to run. Examples: \\"latest\\", \\"beta\\", \\"1.2.3\\", \\"build-tag-name\\""}},"hostedApiKey":"none"},"apify_run_task":{"id":"apify_run_task","name":"APIFY Run Task","description":"Run a saved APIFY actor task synchronously and get dataset items (max 5 minutes)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"APIFY API token from console.apify.com/account#/integrations"},"taskId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Task ID or username/task-name. Examples: \\"janedoe/my-task\\", \\"moJRLRc85AitArpNN\\""},"input":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON string that overrides the task\'s saved input. Example: {\\"startUrls\\": [{\\"url\\": \\"https://example.com\\"}]}"},"itemLimit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Max dataset items to return (1-250000). Example: 500"},"memory":{"type":"number","required":false,"visibility":"user-or-llm","description":"Memory in megabytes allocated for the run (128-32768). Example: 1024 for 1GB"},"timeout":{"type":"number","required":false,"visibility":"user-or-llm","description":"Timeout in seconds for the run. Example: 300 for 5 minutes"},"build":{"type":"string","required":false,"visibility":"user-or-llm","description":"Actor build to run. Examples: \\"latest\\", \\"beta\\", \\"1.2.3\\""}},"hostedApiKey":"none"},"apollo_account_bulk_create":{"id":"apollo_account_bulk_create","name":"Apollo Bulk Create Accounts","description":"Create up to 100 accounts at once in your Apollo database. Set run_dedupe=true to deduplicate by domain, organization_id, and name. Master key required.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"accounts":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of accounts to create (max 100). Each account should include a name, and may optionally include domain, phone, phone_status_cd, raw_address, owner_id, linkedin_url, facebook_url, twitter_url, salesforce_id, and hubspot_id."},"append_label_names":{"type":"array","required":false,"visibility":"user-only","description":"Array of label names to add to ALL accounts in this request"},"run_dedupe":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"When true, performs aggressive deduplication by domain, organization_id, and name (defaults to false)"}},"hostedApiKey":"none"},"apollo_account_bulk_update":{"id":"apollo_account_bulk_update","name":"Apollo Bulk Update Accounts","description":"Update up to 1000 existing accounts at once in your Apollo database (higher limit than contacts!). Each account must include an id field. Master key required.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"account_ids":{"type":"array","required":false,"visibility":"user-or-llm","description":"Array of account IDs to update with the same values (max 1000). Use with name/owner_id for uniform updates. Use either this OR account_attributes."},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"When using account_ids, apply this name to all accounts"},"owner_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"When using account_ids, apply this owner to all accounts"},"account_stage_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"When using account_ids, apply this account stage to all accounts"},"account_attributes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of account objects with individual updates (each must include id). Example: [{\\"id\\": \\"acc1\\", \\"name\\": \\"Acme\\", \\"owner_id\\": \\"u1\\", \\"account_stage_id\\": \\"s1\\", \\"typed_custom_fields\\": {\\"field_id\\": \\"value\\"}}]"},"async":{"type":"boolean","required":false,"visibility":"user-only","description":"When true, processes the update asynchronously. Only supported when using account_ids; returns 422 if used with account_attributes."}},"hostedApiKey":"none"},"apollo_account_create":{"id":"apollo_account_create","name":"Apollo Create Account","description":"Create a new account (company) in your Apollo database","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Company name (e.g., \\"Acme Corporation\\")"},"domain":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company domain without www. prefix (e.g., \\"acme.com\\")"},"phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Primary phone number for the account"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"Apollo user ID of the account owner"},"account_stage_id":{"type":"string","required":false,"visibility":"user-only","description":"Apollo ID for the account stage to assign this account to"},"raw_address":{"type":"string","required":false,"visibility":"user-or-llm","description":"Corporate location (e.g., \\"San Francisco, CA, USA\\")"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-only","description":"Custom field values as { custom_field_id: value } map"}},"hostedApiKey":"none"},"apollo_account_search":{"id":"apollo_account_search","name":"Apollo Search Accounts","description":"Search your team\'s accounts in Apollo. Display limit: 50,000 records (100 records per page, 500 pages max). Use filters to narrow results. Master key required.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"q_organization_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter accounts by organization name (partial-match search)"},"account_stage_ids":{"type":"array","required":false,"visibility":"user-only","description":"Filter by account stage IDs"},"account_label_ids":{"type":"array","required":false,"visibility":"user-only","description":"Filter by account label IDs"},"sort_by_field":{"type":"string","required":false,"visibility":"user-or-llm","description":"Sort field: \\"account_last_activity_date\\", \\"account_created_at\\", or \\"account_updated_at\\""},"sort_ascending":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Sort ascending when true. Defaults to descending."},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}},"hostedApiKey":"none"},"apollo_account_update":{"id":"apollo_account_update","name":"Apollo Update Account","description":"Update an existing account in your Apollo database","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"account_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the account to update (e.g., \\"acc_abc123\\")"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company name (e.g., \\"Acme Corporation\\")"},"domain":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company domain (e.g., \\"acme.com\\")"},"phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company phone number"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"Apollo user ID of the account owner"},"account_stage_id":{"type":"string","required":false,"visibility":"user-only","description":"Apollo ID for the account stage to assign this account to"},"raw_address":{"type":"string","required":false,"visibility":"user-or-llm","description":"Corporate location (e.g., \\"San Francisco, CA, USA\\")"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-only","description":"Custom field values as { custom_field_id: value } map"}},"hostedApiKey":"none"},"apollo_contact_bulk_create":{"id":"apollo_contact_bulk_create","name":"Apollo Bulk Create Contacts","description":"Create up to 100 contacts at once in your Apollo database. Supports deduplication to prevent creating duplicate contacts. Master key required.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"contacts":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of contacts to create (max 100). Each contact may include first_name, last_name, email, title, organization_name, account_id, owner_id, contact_stage_id, linkedin_url, phone (single string) or phone_numbers (array of {raw_number, position}), contact_emails, typed_custom_fields, and CRM IDs (salesforce_contact_id, hubspot_id, team_id) for cross-system matching"},"append_label_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Label names to add to all contacts in this request (e.g., [\\"Hot Lead\\"])"},"run_dedupe":{"type":"boolean","required":false,"visibility":"user-only","description":"Enable deduplication to prevent creating duplicate contacts. When true, existing contacts are returned without modification"}},"hostedApiKey":"none"},"apollo_contact_bulk_update":{"id":"apollo_contact_bulk_update","name":"Apollo Bulk Update Contacts","description":"Update up to 100 existing contacts at once in your Apollo database. Each contact must include an id field. Master key required.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"contact_ids":{"type":"array","required":false,"visibility":"user-or-llm","description":"Array of contact IDs to update. Must be paired with an object-form contact_attributes specifying the fields to apply uniformly to all listed contacts."},"contact_attributes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Required. Either an array of per-contact updates (each with id) — used standalone — or a single object of attributes to apply to all contact_ids. Supported fields: owner_id, email, organization_name, title, first_name, last_name, account_id, present_raw_address, linkedin_url, typed_custom_fields"},"async":{"type":"boolean","required":false,"visibility":"user-only","description":"Force asynchronous processing. Automatically enabled for >100 contacts"}},"hostedApiKey":"none"},"apollo_contact_create":{"id":"apollo_contact_create","name":"Apollo Create Contact","description":"Create a new contact in your Apollo database","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"first_name":{"type":"string","required":true,"visibility":"user-or-llm","description":"First name of the contact"},"last_name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Last name of the contact"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"Email address of the contact"},"title":{"type":"string","required":false,"visibility":"user-or-llm","description":"Job title (e.g., \\"VP of Sales\\", \\"Software Engineer\\")"},"account_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"Apollo account ID to associate with (e.g., \\"acc_abc123\\")"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"User ID of the contact owner (accepted by Apollo but not officially documented for POST /contacts)"},"organization_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Name of the contact\'s employer (e.g., \\"Apollo\\")"},"website_url":{"type":"string","required":false,"visibility":"user-or-llm","description":"Corporate website URL (e.g., \\"https://www.apollo.io/\\")"},"label_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Lists/labels to add the contact to (e.g., [\\"Prospects\\"])"},"contact_stage_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"Apollo ID for the contact stage"},"present_raw_address":{"type":"string","required":false,"visibility":"user-or-llm","description":"Personal location for the contact (e.g., \\"Atlanta, United States\\")"},"direct_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Primary phone number"},"corporate_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Work/office phone number"},"mobile_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Mobile phone number"},"home_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Home phone number"},"other_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Alternative phone number"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-or-llm","description":"Custom field values keyed by custom field ID"},"run_dedupe":{"type":"boolean","required":false,"visibility":"user-only","description":"When true, Apollo deduplicates against existing contacts"}},"hostedApiKey":"none"},"apollo_contact_search":{"id":"apollo_contact_search","name":"Apollo Search Contacts","description":"Search your team\'s contacts in Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"q_keywords":{"type":"string","required":false,"visibility":"user-or-llm","description":"Keywords to search for"},"contact_stage_ids":{"type":"array","required":false,"visibility":"user-only","description":"Filter by contact stage IDs"},"contact_label_ids":{"type":"array","required":false,"visibility":"user-only","description":"Filter by Apollo label IDs (lists)"},"sort_by_field":{"type":"string","required":false,"visibility":"user-only","description":"Sort field: contact_last_activity_date, contact_email_last_opened_at, contact_email_last_clicked_at, contact_created_at, or contact_updated_at"},"sort_ascending":{"type":"boolean","required":false,"visibility":"user-only","description":"When true, sort ascending. Must be used together with sort_by_field"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}},"hostedApiKey":"none"},"apollo_contact_update":{"id":"apollo_contact_update","name":"Apollo Update Contact","description":"Update an existing contact in your Apollo database","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"contact_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the contact to update (e.g., \\"con_abc123\\")"},"first_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"First name of the contact"},"last_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Last name of the contact"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"Email address"},"title":{"type":"string","required":false,"visibility":"user-or-llm","description":"Job title (e.g., \\"VP of Sales\\", \\"Software Engineer\\")"},"account_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"Apollo account ID (e.g., \\"acc_abc123\\")"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"User ID of the contact owner (accepted by Apollo but not officially documented for PATCH /contacts/{id})"},"organization_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Name of the contact\'s employer (e.g., \\"Apollo\\")"},"website_url":{"type":"string","required":false,"visibility":"user-or-llm","description":"Corporate website URL (e.g., \\"https://www.apollo.io/\\")"},"label_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Lists/labels to add the contact to (e.g., [\\"Prospects\\"])"},"contact_stage_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"Apollo ID for the contact stage"},"present_raw_address":{"type":"string","required":false,"visibility":"user-or-llm","description":"Personal location for the contact (e.g., \\"Atlanta, United States\\")"},"direct_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Primary phone number"},"corporate_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Work/office phone number"},"mobile_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Mobile phone number"},"home_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Home phone number"},"other_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Alternative phone number"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-or-llm","description":"Custom field values keyed by custom field ID"}},"hostedApiKey":"none"},"apollo_email_accounts":{"id":"apollo_email_accounts","name":"Apollo Get Email Accounts","description":"Get list of team\'s linked email accounts in Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"}},"hostedApiKey":"none"},"apollo_opportunity_create":{"id":"apollo_opportunity_create","name":"Apollo Create Opportunity","description":"Create a new deal for an account in your Apollo database (master key required)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the opportunity/deal (e.g., \\"Enterprise License - Q1\\")"},"account_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"ID of the account this opportunity belongs to (e.g., \\"acc_abc123\\")"},"amount":{"type":"string","required":false,"visibility":"user-or-llm","description":"Monetary value as a plain number string with no commas or currency symbols"},"opportunity_stage_id":{"type":"string","required":false,"visibility":"user-only","description":"ID of the opportunity stage"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"User ID of the opportunity owner"},"closed_date":{"type":"string","required":false,"visibility":"user-or-llm","description":"Expected close date in YYYY-MM-DD format"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-only","description":"Custom field values as { custom_field_id: value } map"}},"hostedApiKey":"none"},"apollo_opportunity_get":{"id":"apollo_opportunity_get","name":"Apollo Get Opportunity","description":"Retrieve complete details of a specific deal/opportunity by ID","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"opportunity_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the opportunity to retrieve (e.g., \\"opp_abc123\\")"}},"hostedApiKey":"none"},"apollo_opportunity_search":{"id":"apollo_opportunity_search","name":"Apollo Search Opportunities","description":"Search and list all deals/opportunities in your team\'s Apollo account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"sort_by_field":{"type":"string","required":false,"visibility":"user-or-llm","description":"Sort field: \\"amount\\", \\"is_closed\\", or \\"is_won\\""},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}},"hostedApiKey":"none"},"apollo_opportunity_update":{"id":"apollo_opportunity_update","name":"Apollo Update Opportunity","description":"Update an existing deal/opportunity in your Apollo database","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"opportunity_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the opportunity to update (e.g., \\"opp_abc123\\")"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Name of the opportunity/deal (e.g., \\"Enterprise License - Q1\\")"},"amount":{"type":"string","required":false,"visibility":"user-or-llm","description":"Monetary value as a plain number string with no commas or currency symbols"},"opportunity_stage_id":{"type":"string","required":false,"visibility":"user-only","description":"ID of the opportunity stage"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"User ID of the opportunity owner"},"closed_date":{"type":"string","required":false,"visibility":"user-or-llm","description":"Expected close date in YYYY-MM-DD format"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-only","description":"Custom field values as { custom_field_id: value } map"}},"hostedApiKey":"none"},"apollo_organization_bulk_enrich":{"id":"apollo_organization_bulk_enrich","name":"Apollo Bulk Organization Enrichment","description":"Enrich data for up to 10 organizations at once using Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"domains":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of company domains to enrich (max 10, no www. or @, e.g., [\\"apollo.io\\", \\"stripe.com\\"])"}},"hostedApiKey":"none"},"apollo_organization_enrich":{"id":"apollo_organization_enrich","name":"Apollo Organization Enrichment","description":"Enrich data for a single organization using Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"domain":{"type":"string","required":true,"visibility":"user-or-llm","description":"Company domain (e.g., \\"apollo.io\\", \\"acme.com\\")"}},"hostedApiKey":"none"},"apollo_organization_search":{"id":"apollo_organization_search","name":"Apollo Organization Search","description":"Search Apollo\'s database for companies using filters","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"organization_locations":{"type":"array","required":false,"visibility":"user-or-llm","description":"Company HQ locations (cities, US states, or countries)"},"organization_not_locations":{"type":"array","required":false,"visibility":"user-or-llm","description":"Exclude companies whose HQ is in these locations"},"organization_num_employees_ranges":{"type":"array","required":false,"visibility":"user-or-llm","description":"Employee count ranges as \\"min,max\\" strings (e.g., [\\"1,10\\", \\"250,500\\", \\"10000,20000\\"])"},"q_organization_keyword_tags":{"type":"array","required":false,"visibility":"user-or-llm","description":"Industry or keyword tags"},"q_organization_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Organization name to search for (e.g., \\"Acme\\", \\"TechCorp\\")"},"organization_ids":{"type":"array","required":false,"visibility":"user-or-llm","description":"Apollo organization IDs to include (e.g., [\\"5e66b6381e05b4008c8331b8\\"])"},"q_organization_domains_list":{"type":"array","required":false,"visibility":"user-or-llm","description":"Domain names to filter by (no www. or @, up to 1,000)"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}},"hostedApiKey":"none"},"apollo_people_bulk_enrich":{"id":"apollo_people_bulk_enrich","name":"Apollo Bulk People Enrichment","description":"Enrich data for up to 10 people at once using Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"people":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of people to enrich (max 10)"},"reveal_personal_emails":{"type":"boolean","required":false,"visibility":"user-only","description":"Reveal personal email addresses (uses credits)"},"reveal_phone_number":{"type":"boolean","required":false,"visibility":"user-only","description":"Reveal phone numbers (uses credits, requires webhook_url)"},"webhook_url":{"type":"string","required":false,"visibility":"user-only","description":"Webhook URL for async phone number delivery (required when reveal_phone_number is true)"}},"hostedApiKey":"none"},"apollo_people_enrich":{"id":"apollo_people_enrich","name":"Apollo People Enrichment","description":"Enrich data for a single person using Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"first_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"First name of the person"},"last_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Last name of the person"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Full name of the person (alternative to first_name/last_name)"},"id":{"type":"string","required":false,"visibility":"user-or-llm","description":"Apollo ID for the person"},"hashed_email":{"type":"string","required":false,"visibility":"user-or-llm","description":"MD5 or SHA-256 hashed email"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"Email address of the person"},"organization_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company name where the person works"},"domain":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company domain (e.g., \\"apollo.io\\", \\"acme.com\\")"},"linkedin_url":{"type":"string","required":false,"visibility":"user-or-llm","description":"LinkedIn profile URL"},"reveal_personal_emails":{"type":"boolean","required":false,"visibility":"user-only","description":"Reveal personal email addresses (uses credits)"},"reveal_phone_number":{"type":"boolean","required":false,"visibility":"user-only","description":"Reveal phone numbers (uses credits, requires webhook_url)"},"webhook_url":{"type":"string","required":false,"visibility":"user-only","description":"Webhook URL for async phone number delivery (required when reveal_phone_number is true)"}},"hostedApiKey":"none"},"apollo_people_search":{"id":"apollo_people_search","name":"Apollo People Search","description":"Search Apollo\'s database for people using demographic filters","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"person_titles":{"type":"array","required":false,"visibility":"user-or-llm","description":"Job titles to search for (e.g., [\\"CEO\\", \\"VP of Sales\\"])"},"include_similar_titles":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Whether to return people with job titles similar to person_titles"},"person_locations":{"type":"array","required":false,"visibility":"user-or-llm","description":"Locations to search in (e.g., [\\"San Francisco, CA\\", \\"New York, NY\\"])"},"person_seniorities":{"type":"array","required":false,"visibility":"user-or-llm","description":"Seniority levels (one of: owner, founder, c_suite, partner, vp, head, director, manager, senior, entry, intern)"},"organization_ids":{"type":"array","required":false,"visibility":"user-or-llm","description":"Apollo organization IDs to filter by (e.g., [\\"5e66b6381e05b4008c8331b8\\"])"},"organization_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Company names to search within (legacy filter)"},"organization_locations":{"type":"array","required":false,"visibility":"user-or-llm","description":"Headquarters locations of the people\'s current employer (e.g., [\'texas\', \'tokyo\', \'spain\'])"},"q_organization_domains_list":{"type":"array","required":false,"visibility":"user-or-llm","description":"Employer domain names (e.g., [\\"apollo.io\\", \\"microsoft.com\\"]) — up to 1,000, no www. or @"},"organization_num_employees_ranges":{"type":"array","required":false,"visibility":"user-or-llm","description":"Employee count ranges for the person\'s current employer. Each entry is \\"min,max\\" (e.g., [\\"1,10\\", \\"250,500\\", \\"10000,20000\\"])"},"contact_email_status":{"type":"array","required":false,"visibility":"user-or-llm","description":"Email statuses to filter by: \\"verified\\", \\"unverified\\", \\"likely to engage\\", \\"unavailable\\""},"q_keywords":{"type":"string","required":false,"visibility":"user-or-llm","description":"Keywords to search for"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination, default 1 (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, default 25, max 100 (e.g., 25, 50, 100)"}},"hostedApiKey":"none"},"apollo_sequence_add_contacts":{"id":"apollo_sequence_add_contacts","name":"Apollo Add Contacts to Sequence","description":"Add contacts to an Apollo sequence","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"sequence_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the sequence to add contacts to (e.g., \\"seq_abc123\\")"},"contact_ids":{"type":"array","required":false,"visibility":"user-or-llm","description":"Array of contact IDs to add to the sequence (e.g., [\\"con_abc123\\", \\"con_def456\\"]). Either contact_ids or label_names must be provided."},"label_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Array of label names to identify contacts to add to the sequence. Either contact_ids or label_names must be provided."},"send_email_from_email_account_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the email account to send from. Use the Get Email Accounts operation to look this up."},"send_email_from_email_address":{"type":"string","required":false,"visibility":"user-only","description":"Specific email address to send from within the email account."},"sequence_no_email":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts even if they have no email address"},"sequence_unverified_email":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts with unverified email addresses"},"sequence_job_change":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts who recently changed jobs"},"sequence_active_in_other_campaigns":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts active in other campaigns"},"sequence_finished_in_other_campaigns":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts who finished other campaigns"},"sequence_same_company_in_same_campaign":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts even if others from the same company are in the sequence"},"contacts_without_ownership_permission":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts without ownership permission"},"add_if_in_queue":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts even if they are in the queue"},"contact_verification_skipped":{"type":"boolean","required":false,"visibility":"user-only","description":"Skip contact verification when adding"},"user_id":{"type":"string","required":false,"visibility":"user-only","description":"ID of the user performing the action"},"status":{"type":"string","required":false,"visibility":"user-only","description":"Initial status for added contacts: \\"active\\" or \\"paused\\""},"auto_unpause_at":{"type":"string","required":false,"visibility":"user-only","description":"ISO 8601 datetime to automatically unpause contacts"}},"hostedApiKey":"none"},"apollo_sequence_search":{"id":"apollo_sequence_search","name":"Apollo Search Sequences","description":"Search for sequences/campaigns in your team\'s Apollo account (master key required)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"q_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search sequences by name (e.g., \\"Outbound Q1\\", \\"Follow-up\\")"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}},"hostedApiKey":"none"},"apollo_task_create":{"id":"apollo_task_create","name":"Apollo Create Task","description":"Create one or more tasks in Apollo (one task per contact_id, master key required)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"user_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the Apollo user the task is assigned to"},"contact_ids":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of contact IDs. One task is created per contact."},"priority":{"type":"string","required":false,"visibility":"user-or-llm","description":"Task priority: \\"high\\", \\"medium\\", or \\"low\\" (defaults to \\"medium\\")"},"due_at":{"type":"string","required":true,"visibility":"user-or-llm","description":"Due date/time in ISO 8601 format (e.g., \\"2024-12-31T23:59:59Z\\")"},"type":{"type":"string","required":true,"visibility":"user-or-llm","description":"Task type: \\"call\\", \\"outreach_manual_email\\", \\"linkedin_step_connect\\", \\"linkedin_step_message\\", \\"linkedin_step_view_profile\\", \\"linkedin_step_interact_post\\", or \\"action_item\\""},"status":{"type":"string","required":true,"visibility":"user-or-llm","description":"Task status: \\"scheduled\\", \\"completed\\", or \\"skipped\\""},"note":{"type":"string","required":false,"visibility":"user-or-llm","description":"Free-form note providing context for the task"}},"hostedApiKey":"none"},"apollo_task_search":{"id":"apollo_task_search","name":"Apollo Search Tasks","description":"Search for tasks in Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"sort_by_field":{"type":"string","required":false,"visibility":"user-or-llm","description":"Sort field: \\"task_due_at\\" or \\"task_priority\\""},"open_factor_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Filter by status. Common values: [\\"task_types\\"] for open tasks, [\\"task_completed_at\\"] for completed tasks."},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}},"hostedApiKey":"none"},"appconfig_create_application":{"id":"appconfig_create_application","name":"AppConfig Create Application","description":"Create an application in AWS AppConfig","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the application to create"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"Description of the application"}},"hostedApiKey":"none"},"appconfig_create_configuration_profile":{"id":"appconfig_create_configuration_profile","name":"AppConfig Create Configuration Profile","description":"Create a configuration profile in an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to create the configuration profile in"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the configuration profile"},"locationUri":{"type":"string","required":true,"visibility":"user-or-llm","description":"Where the configuration is stored. Use \\"hosted\\" for AppConfig-hosted configurations, or an SSM/S3 URI"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"Description of the configuration profile"},"retrievalRoleArn":{"type":"string","required":false,"visibility":"user-or-llm","description":"ARN of an IAM role to retrieve the configuration (required for non-hosted URIs)"},"type":{"type":"string","required":false,"visibility":"user-or-llm","description":"Profile type: AWS.Freeform (default) or AWS.AppConfig.FeatureFlags"}},"hostedApiKey":"none"},"appconfig_create_environment":{"id":"appconfig_create_environment","name":"AppConfig Create Environment","description":"Create an environment for an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to create the environment in"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the environment to create"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"Description of the environment"}},"hostedApiKey":"none"},"appconfig_create_hosted_configuration_version":{"id":"appconfig_create_hosted_configuration_version","name":"AppConfig Create Hosted Configuration Version","description":"Create a new hosted configuration version for an AppConfig configuration profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to add the version to"},"content":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration content (e.g., a JSON or YAML document)"},"contentType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Content type of the configuration (e.g., application/json, text/plain)"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"Description of the configuration version"},"latestVersionNumber":{"type":"number","required":false,"visibility":"user-or-llm","description":"The version number of the latest version, used for optimistic concurrency"},"versionLabel":{"type":"string","required":false,"visibility":"user-or-llm","description":"A user-defined label for the configuration version"}},"hostedApiKey":"none"},"appconfig_delete_application":{"id":"appconfig_delete_application","name":"AppConfig Delete Application","description":"Delete an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to delete"}},"hostedApiKey":"none"},"appconfig_delete_configuration_profile":{"id":"appconfig_delete_configuration_profile","name":"AppConfig Delete Configuration Profile","description":"Delete an AWS AppConfig configuration profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to delete"}},"hostedApiKey":"none"},"appconfig_delete_environment":{"id":"appconfig_delete_environment","name":"AppConfig Delete Environment","description":"Delete an AWS AppConfig environment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the environment"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID to delete"}},"hostedApiKey":"none"},"appconfig_delete_hosted_configuration_version":{"id":"appconfig_delete_hosted_configuration_version","name":"AppConfig Delete Hosted Configuration Version","description":"Delete a specific hosted configuration version from an AppConfig profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID that owns the version"},"versionNumber":{"type":"number","required":true,"visibility":"user-or-llm","description":"The version number to delete"}},"hostedApiKey":"none"},"appconfig_get_application":{"id":"appconfig_get_application","name":"AppConfig Get Application","description":"Get details about a single AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to retrieve"}},"hostedApiKey":"none"},"appconfig_get_configuration":{"id":"appconfig_get_configuration","name":"AppConfig Get Configuration","description":"Retrieve the latest deployed configuration for an AppConfig application, environment, and profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID or name to retrieve configuration for"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID or name to retrieve configuration for"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID or name to retrieve"}},"hostedApiKey":"none"},"appconfig_get_configuration_profile":{"id":"appconfig_get_configuration_profile","name":"AppConfig Get Configuration Profile","description":"Get details about a single AWS AppConfig configuration profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to retrieve"}},"hostedApiKey":"none"},"appconfig_get_deployment":{"id":"appconfig_get_deployment","name":"AppConfig Get Deployment","description":"Get details about a specific AWS AppConfig deployment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID of the deployment"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID of the deployment"},"deploymentNumber":{"type":"number","required":true,"visibility":"user-or-llm","description":"The sequence number of the deployment"}},"hostedApiKey":"none"},"appconfig_get_environment":{"id":"appconfig_get_environment","name":"AppConfig Get Environment","description":"Get details about a single AWS AppConfig environment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the environment"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID to retrieve"}},"hostedApiKey":"none"},"appconfig_get_hosted_configuration_version":{"id":"appconfig_get_hosted_configuration_version","name":"AppConfig Get Hosted Configuration Version","description":"Retrieve a specific hosted configuration version from an AppConfig profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to read the version from"},"versionNumber":{"type":"number","required":true,"visibility":"user-or-llm","description":"The version number to retrieve"}},"hostedApiKey":"none"},"appconfig_list_applications":{"id":"appconfig_list_applications","name":"AppConfig List Applications","description":"List applications in AWS AppConfig","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of applications to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}},"hostedApiKey":"none"},"appconfig_list_configuration_profiles":{"id":"appconfig_list_configuration_profiles","name":"AppConfig List Configuration Profiles","description":"List configuration profiles for an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profiles"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of configuration profiles to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}},"hostedApiKey":"none"},"appconfig_list_deployment_strategies":{"id":"appconfig_list_deployment_strategies","name":"AppConfig List Deployment Strategies","description":"List deployment strategies available in AWS AppConfig","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of deployment strategies to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}},"hostedApiKey":"none"},"appconfig_list_deployments":{"id":"appconfig_list_deployments","name":"AppConfig List Deployments","description":"List deployments for an AWS AppConfig environment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID of the deployments"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID of the deployments"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of deployments to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}},"hostedApiKey":"none"},"appconfig_list_environments":{"id":"appconfig_list_environments","name":"AppConfig List Environments","description":"List environments for an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the environments"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of environments to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}},"hostedApiKey":"none"},"appconfig_list_hosted_configuration_versions":{"id":"appconfig_list_hosted_configuration_versions","name":"AppConfig List Hosted Configuration Versions","description":"List hosted configuration versions for an AWS AppConfig configuration profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to list versions for"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of versions to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}},"hostedApiKey":"none"},"appconfig_start_deployment":{"id":"appconfig_start_deployment","name":"AppConfig Start Deployment","description":"Start deploying a configuration version to an AWS AppConfig environment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to deploy in"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID to deploy to"},"deploymentStrategyId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The deployment strategy ID to use"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to deploy"},"configurationVersion":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration version to deploy"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"Description of the deployment"}},"hostedApiKey":"none"},"appconfig_stop_deployment":{"id":"appconfig_stop_deployment","name":"AppConfig Stop Deployment","description":"Stop an in-progress AWS AppConfig deployment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID of the deployment"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID of the deployment"},"deploymentNumber":{"type":"number","required":true,"visibility":"user-or-llm","description":"The sequence number of the deployment to stop"}},"hostedApiKey":"none"},"appconfig_update_application":{"id":"appconfig_update_application","name":"AppConfig Update Application","description":"Update the name or description of an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to update"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"New name for the application"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"New description for the application"}},"hostedApiKey":"none"},"appconfig_update_configuration_profile":{"id":"appconfig_update_configuration_profile","name":"AppConfig Update Configuration Profile","description":"Update the name, description, or retrieval role of an AppConfig configuration profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to update"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"New name for the configuration profile"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"New description for the configuration profile"},"retrievalRoleArn":{"type":"string","required":false,"visibility":"user-or-llm","description":"New ARN of the IAM role used to retrieve the configuration"}},"hostedApiKey":"none"},"appconfig_update_environment":{"id":"appconfig_update_environment","name":"AppConfig Update Environment","description":"Update the name or description of an AWS AppConfig environment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the environment"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID to update"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"New name for the environment"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"New description for the environment"}},"hostedApiKey":"none"},"arxiv_get_author_papers":{"id":"arxiv_get_author_papers","name":"ArXiv Get Author Papers","description":"Search for papers by a specific author on ArXiv.","version":"1.0.0","params":{"authorName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Author name to search for"},"maxResults":{"type":"number","required":false,"visibility":"user-only","description":"Maximum number of results to return (default: 10, max: 2000)"}},"hostedApiKey":"none"},"arxiv_get_paper":{"id":"arxiv_get_paper","name":"ArXiv Get Paper","description":"Get detailed information about a specific ArXiv paper by its ID.","version":"1.0.0","params":{"paperId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ArXiv paper ID (e.g., \\"1706.03762\\")"}},"hostedApiKey":"none"},"arxiv_search":{"id":"arxiv_search","name":"ArXiv Search","description":"Search for academic papers on ArXiv by keywords, authors, titles, or other fields.","version":"1.0.0","params":{"searchQuery":{"type":"string","required":true,"visibility":"user-or-llm","description":"The search query to execute"},"searchField":{"type":"string","required":false,"visibility":"user-only","description":"Field to search in: all, ti (title), au (author), abs (abstract), co (comment), jr (journal), cat (category), rn (report number)"},"maxResults":{"type":"number","required":false,"visibility":"user-only","description":"Maximum number of results to return (default: 10, max: 2000)"},"sortBy":{"type":"string","required":false,"visibility":"user-only","description":"Sort by: relevance, lastUpdatedDate, submittedDate (default: relevance)"},"sortOrder":{"type":"string","required":false,"visibility":"user-only","description":"Sort order: ascending, descending (default: descending)"}},"hostedApiKey":"none"},"asana_add_comment":{"id":"asana_add_comment","name":"Asana Add Comment","description":"Add a comment (story) to an Asana task","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana task GID (numeric string)"},"text":{"type":"string","required":true,"visibility":"user-or-llm","description":"The text content of the comment"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_add_followers":{"id":"asana_add_followers","name":"Asana Add Followers","description":"Add one or more followers to an Asana task","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"GID of the Asana task (numeric string)"},"followers":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of user GIDs to add as followers to the task"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_create_project":{"id":"asana_create_project","name":"Asana Create Project","description":"Create a new project in an Asana workspace","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"workspace":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana workspace GID (numeric string) where the project will be created"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the project"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"Notes or description for the project"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_create_section":{"id":"asana_create_section","name":"Asana Create Section","description":"Create a new section in an Asana project","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"projectGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"GID of the Asana project (numeric string) to add the section to"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the section"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_create_subtask":{"id":"asana_create_subtask","name":"Asana Create Subtask","description":"Create a subtask under an existing Asana task","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"GID of the parent Asana task (numeric string)"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the subtask"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"Notes or description for the subtask"},"assignee":{"type":"string","required":false,"visibility":"user-or-llm","description":"User GID to assign the subtask to"},"due_on":{"type":"string","required":false,"visibility":"user-or-llm","description":"Due date in YYYY-MM-DD format"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_create_task":{"id":"asana_create_task","name":"Asana Create Task","description":"Create a new task in Asana","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"workspace":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana workspace GID (numeric string) where the task will be created"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the task"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"Notes or description for the task"},"assignee":{"type":"string","required":false,"visibility":"user-or-llm","description":"User GID to assign the task to"},"due_on":{"type":"string","required":false,"visibility":"user-or-llm","description":"Due date in YYYY-MM-DD format"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_delete_task":{"id":"asana_delete_task","name":"Asana Delete Task","description":"Delete an Asana task by its GID (moves it to the trash)","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"GID of the Asana task to delete (numeric string)"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_get_project":{"id":"asana_get_project","name":"Asana Get Project","description":"Retrieve a single Asana project by its GID","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"projectGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana project GID (numeric string) to retrieve"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_get_projects":{"id":"asana_get_projects","name":"Asana Get Projects","description":"Retrieve all projects from an Asana workspace","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"workspace":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana workspace GID (numeric string) to retrieve projects from"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_get_task":{"id":"asana_get_task","name":"Asana Get Task","description":"Retrieve a single task by GID or get multiple tasks with filters","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":false,"visibility":"user-or-llm","description":"The globally unique identifier (GID) of the task. If not provided, will get multiple tasks."},"workspace":{"type":"string","required":false,"visibility":"user-or-llm","description":"Asana workspace GID (numeric string) to filter tasks (required when not using taskGid)"},"project":{"type":"string","required":false,"visibility":"user-or-llm","description":"Asana project GID (numeric string) to filter tasks"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of tasks to return (default: 50)"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_list_sections":{"id":"asana_list_sections","name":"Asana List Sections","description":"List all sections in an Asana project","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"projectGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"GID of the Asana project (numeric string) to list sections from"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_list_workspaces":{"id":"asana_list_workspaces","name":"Asana List Workspaces","description":"List all Asana workspaces and organizations the authenticated user belongs to","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_search_tasks":{"id":"asana_search_tasks","name":"Asana Search Tasks","description":"Search for tasks in an Asana workspace","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"workspace":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana workspace GID (numeric string) to search tasks in"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Text to search for in task names"},"assignee":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter tasks by assignee user GID"},"projects":{"type":"array","required":false,"visibility":"user-or-llm","description":"Array of Asana project GIDs (numeric strings) to filter tasks by"},"completed":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Filter by completion status"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_update_task":{"id":"asana_update_task","name":"Asana Update Task","description":"Update an existing task in Asana","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana task GID (numeric string) of the task to update"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Updated name for the task"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"Updated notes or description for the task"},"assignee":{"type":"string","required":false,"visibility":"user-or-llm","description":"Updated assignee user GID"},"completed":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Mark task as completed or not completed"},"due_on":{"type":"string","required":false,"visibility":"user-or-llm","description":"Updated due date in YYYY-MM-DD format"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"ashby_add_candidate_tag":{"id":"ashby_add_candidate_tag","name":"Ashby Add Candidate Tag","description":"Adds a tag to a candidate in Ashby and returns the updated candidate.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"onBehalfOfUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Active Ashby user UUID to attribute this mutation to; the API key must permit on-behalf-of calls"},"candidateId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the candidate to add the tag to"},"tagId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the tag to add"}},"hostedApiKey":"none"},"ashby_anonymize_candidate":{"id":"ashby_anonymize_candidate","name":"Ashby Anonymize Candidate","description":"Strips personally identifiable information from a candidate in Ashby. This does not delete the candidate - the record and its applications remain, with the PII removed. Ashby exposes no candidate deletion endpoint; true deletion is UI-only, restricted by role, and limited to a 10-day window. Requires the candidatesWrite permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"onBehalfOfUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Active Ashby user UUID to attribute this mutation to; the API key must permit on-behalf-of calls"},"candidateId":{"type":"string","required":true,"visibility":"user-or-llm","description":"UUID of the candidate to anonymize"}},"hostedApiKey":"none"},"ashby_change_application_source":{"id":"ashby_change_application_source","name":"Ashby Change Application Source","description":"Changes the source attributed to an existing application, so programmatically created applications report correctly on the recruiting side. Requires the candidatesWrite permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"onBehalfOfUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Active Ashby user UUID to attribute this mutation to; the API key must permit on-behalf-of calls"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"UUID of the application whose source should change"},"sourceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the source to attribute the application to, as returned by List Sources. Omit only when unsetSource is true."},"unsetSource":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Set true to deliberately clear the application source. Required to unset, so that a missing or empty sourceId cannot wipe attribution by accident."}},"hostedApiKey":"none"},"ashby_change_application_stage":{"id":"ashby_change_application_stage","name":"Ashby Change Application Stage","description":"Moves an application to a different interview stage. Requires an archive reason when moving to an Archived stage.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"onBehalfOfUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Active Ashby user UUID to attribute this mutation to; the API key must permit on-behalf-of calls"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the application to update the stage of"},"interviewStageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the interview stage to move the application to"},"archiveReasonId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Archive reason UUID. Required when moving to an Archived stage, ignored otherwise"},"archiveEmail":{"type":"json","required":false,"visibility":"user-or-llm","description":"Archive email configuration with communicationTemplateId and optional sendAt ISO 8601 timestamp. Pass null or omit to send no archive email."}},"hostedApiKey":"none"},"ashby_create_application":{"id":"ashby_create_application","name":"Ashby Create Application","description":"Creates a new application for a candidate on a job. Optionally specify interview plan, stage, source, and credited user.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"onBehalfOfUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Active Ashby user UUID to attribute this mutation to; the API key must permit on-behalf-of calls"},"candidateId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the candidate to consider for the job"},"jobId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the job to consider the candidate for"},"interviewPlanId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the interview plan to use (defaults to the job default plan)"},"interviewStageId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the interview stage to place the application in, or FirstPreInterviewScreen (defaults to the first Lead stage)"},"sourceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the source to set on the application"},"creditedToUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the user the application is credited to"},"createdAt":{"type":"string","required":false,"visibility":"user-or-llm","description":"ISO 8601 timestamp to set as the application creation date (defaults to now)"},"applicationHistory":{"type":"json","required":false,"visibility":"user-or-llm","description":"Optional documented application history entries to create with the application"}},"hostedApiKey":"none"},"ashby_create_candidate":{"id":"ashby_create_candidate","name":"Ashby Create Candidate","description":"Creates a new candidate record in Ashby.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"onBehalfOfUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Active Ashby user UUID to attribute this mutation to; the API key must permit on-behalf-of calls"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"The candidate full name"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"Primary email address for the candidate"},"phoneNumber":{"type":"string","required":false,"visibility":"user-or-llm","description":"Primary phone number for the candidate"},"linkedInUrl":{"type":"string","required":false,"visibility":"user-or-llm","description":"LinkedIn profile URL"},"githubUrl":{"type":"string","required":false,"visibility":"user-or-llm","description":"GitHub profile URL"},"website":{"type":"string","required":false,"visibility":"user-or-llm","description":"Personal website URL"},"sourceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the source to attribute the candidate to"},"creditedToUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the Ashby user to credit with sourcing this candidate"},"createdAt":{"type":"string","required":false,"visibility":"user-or-llm","description":"Backdated creation timestamp in ISO 8601 (e.g. 2024-01-01T00:00:00Z). Defaults to now."},"alternateEmailAddresses":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of additional email address strings to add to the candidate, e.g. [\\"a@x.com\\",\\"b@y.com\\"]"},"location":{"type":"json","required":false,"visibility":"user-or-llm","description":"Candidate location object with optional city, region, and country"}},"hostedApiKey":"none"},"ashby_create_note":{"id":"ashby_create_note","name":"Ashby Create Note","description":"Creates a note on a candidate in Ashby. Supports plain text and HTML content (bold, italic, underline, links, lists, code).","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"onBehalfOfUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Active Ashby user UUID to attribute this mutation to; the API key must permit on-behalf-of calls"},"candidateId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the candidate to add the note to"},"note":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note content. If noteType is text/html, supports: , , , ,