diff --git a/apps/sim/lib/credentials/client-credential-accounts/descriptors.test.ts b/apps/sim/lib/credentials/client-credential-accounts/descriptors.test.ts index 5ea3e35e79d..66d0f04042c 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/descriptors.test.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/descriptors.test.ts @@ -7,6 +7,8 @@ import { getClientCredentialAccountDescriptor, NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID, normalizeNetSuiteSuiteTalkOrigin, + normalizeOracleFusionApplicationOrigin, + ORACLE_FUSION_SERVICE_ACCOUNT_PROVIDER_ID, partitionClientCredentialFields, resolveClientCredentialAuthMethod, resolveSalesforceAuthMethod, @@ -19,6 +21,9 @@ const salesforce = getClientCredentialAccountDescriptor(SALESFORCE_SERVICE_ACCOU const box = getClientCredentialAccountDescriptor(BOX_SERVICE_ACCOUNT_PROVIDER_ID)! const zohoDesk = getClientCredentialAccountDescriptor(ZOHO_DESK_SERVICE_ACCOUNT_PROVIDER_ID)! const netSuite = getClientCredentialAccountDescriptor(NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID)! +const oracleFusion = getClientCredentialAccountDescriptor( + ORACLE_FUSION_SERVICE_ACCOUNT_PROVIDER_ID +)! const ids = (fields: { id: string }[]) => fields.map((field) => field.id) @@ -51,6 +56,17 @@ describe('partitionClientCredentialFields', () => { multiline: true, }) }) + + it('reuses the existing fields for an Oracle Fusion integration user', () => { + const { visible, required } = partitionClientCredentialFields(oracleFusion, undefined) + expect(ids(visible)).toEqual(['orgId', 'clientId', 'clientSecret']) + expect(ids(required)).toEqual(['orgId', 'clientId', 'clientSecret']) + expect(oracleFusion.fields).toEqual([ + expect.objectContaining({ id: 'orgId', label: 'Fusion Applications URL', secret: false }), + expect.objectContaining({ id: 'clientId', label: 'Integration username', secret: false }), + expect.objectContaining({ id: 'clientSecret', label: 'Password', secret: true }), + ]) + }) }) describe('Salesforce, which offers two grants', () => { @@ -109,6 +125,41 @@ describe('normalizeNetSuiteSuiteTalkOrigin', () => { }) }) +describe('normalizeOracleFusionApplicationOrigin', () => { + it.each([ + [' https://VISION.fa.us2.oraclecloud.com/ ', 'https://vision.fa.us2.oraclecloud.com'], + ['https://acme-prod.fa.ocs.oraclecloud.com', 'https://acme-prod.fa.ocs.oraclecloud.com'], + [ + 'https://pod.fa.eu-frankfurt-1.oraclecloud.com', + 'https://pod.fa.eu-frankfurt-1.oraclecloud.com', + ], + ])('normalizes the supported application origin %j', (value, expected) => { + expect(normalizeOracleFusionApplicationOrigin(value)).toBe(expected) + }) + + it.each([ + 'http://vision.fa.us2.oraclecloud.com', + 'https://vision.fa.us2.oraclecloud.com/path', + 'https://vision.fa.us2.oraclecloud.com/path/..', + 'https://vision.fa.us2.oraclecloud.com/./', + 'https://vision.fa.us2.oraclecloud.com/%2e%2e/', + 'https://vision.fa.us2.oraclecloud.com:443', + 'https://vision.fa.us2.oraclecloud.com:8443', + 'https://user@vision.fa.us2.oraclecloud.com', + 'https://user:password@vision.fa.us2.oraclecloud.com', + 'https://vision.fa.us2.oraclecloud.com?tenant=other', + 'https://vision.fa.us2.oraclecloud.com#fragment', + 'https://vision.fa.us2.oraclecloud.com.evil.example', + 'https://vision.fa.us2.oraclecloud.co', + 'https://fusion.example.com', + 'https://fa.us2.oraclecloud.com', + 'https://-vision.fa.us2.oraclecloud.com', + 'https://vision.fa.-us2.oraclecloud.com', + ])('rejects the noncanonical Fusion Applications URL %j', (value) => { + expect(normalizeOracleFusionApplicationOrigin(value)).toBeUndefined() + }) +}) + describe('resolveClientCredentialAuthMethod', () => { it('returns undefined for a provider that declares no method selector', () => { expect(resolveClientCredentialAuthMethod(box, 'jwt_bearer')).toBeUndefined() diff --git a/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts b/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts index dcd4aa26f7d..7f98eb343c6 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts @@ -111,6 +111,7 @@ export const BOX_SERVICE_ACCOUNT_PROVIDER_ID = 'box-service-account' as const export const SALESFORCE_SERVICE_ACCOUNT_PROVIDER_ID = 'salesforce-service-account' as const export const ZOHO_DESK_SERVICE_ACCOUNT_PROVIDER_ID = 'zoho-desk-service-account' as const export const NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID = 'netsuite-service-account' as const +export const ORACLE_FUSION_SERVICE_ACCOUNT_PROVIDER_ID = 'oracle-fusion-service-account' as const export type ClientCredentialAccountProviderId = | typeof ZOOM_SERVICE_ACCOUNT_PROVIDER_ID @@ -118,6 +119,7 @@ export type ClientCredentialAccountProviderId = | typeof SALESFORCE_SERVICE_ACCOUNT_PROVIDER_ID | typeof ZOHO_DESK_SERVICE_ACCOUNT_PROVIDER_ID | typeof NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID + | typeof ORACLE_FUSION_SERVICE_ACCOUNT_PROVIDER_ID /** * Exact account-specific SuiteTalk origin accepted by NetSuite's OAuth and @@ -154,6 +156,40 @@ export function normalizeNetSuiteSuiteTalkOrigin(rawUrl: string): string | undef } } +/** Canonical Oracle-assigned Fusion Applications origin used by product REST APIs. */ +export const ORACLE_FUSION_APPLICATION_ORIGIN_REGEX = + /^https:\/\/[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.fa\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.oraclecloud\.com$/ +const ORACLE_FUSION_APPLICATION_INPUT_REGEX = + /^https:\/\/[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.fa\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.oraclecloud\.com\/?$/i + +/** + * Normalizes a Fusion Applications URL to its authoritative HTTPS origin. + * Explicit ports are rejected even when they match HTTPS's default port so a + * saved credential can never silently broaden the accepted endpoint shape. + */ +export function normalizeOracleFusionApplicationOrigin(rawUrl: string): string | undefined { + try { + const trimmed = rawUrl.trim() + if (!ORACLE_FUSION_APPLICATION_INPUT_REGEX.test(trimmed)) return undefined + const parsed = new URL(trimmed) + if ( + parsed.protocol !== 'https:' || + parsed.port || + parsed.username || + parsed.password || + parsed.search || + parsed.hash || + (parsed.pathname !== '' && parsed.pathname !== '/') || + !ORACLE_FUSION_APPLICATION_ORIGIN_REGEX.test(parsed.origin) + ) { + return undefined + } + return parsed.origin + } catch { + return undefined + } +} + /** * Allowed My Domain host shapes: one org label (optionally with a * `--sandboxName` suffix), an optional partition label (sandbox, develop, @@ -531,6 +567,39 @@ export const CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS: Record< helpText: 'Use the account-specific SuiteTalk URL and the client ID, certificate ID, and private key from one OAuth 2.0 client-credentials mapping.', }, + [ORACLE_FUSION_SERVICE_ACCOUNT_PROVIDER_ID]: { + providerId: ORACLE_FUSION_SERVICE_ACCOUNT_PROVIDER_ID, + serviceLabel: 'Oracle Fusion', + connectNoun: 'integration user', + fields: [ + { + id: 'orgId', + label: 'Fusion Applications URL', + placeholder: 'https://your-environment.fa.ocs.oraclecloud.com', + secret: false, + hintPattern: ORACLE_FUSION_APPLICATION_ORIGIN_REGEX, + hintNormalize: (value) => + normalizeOracleFusionApplicationOrigin(value) ?? value.trim().toLowerCase(), + hintMessage: + 'Expected the Oracle-assigned HTTPS application URL with no path, port, credentials, query, or fragment.', + }, + { + id: 'clientId', + label: 'Integration username', + placeholder: 'Paste the integration username', + secret: false, + }, + { + id: 'clientSecret', + label: 'Password', + placeholder: 'Paste the password', + secret: true, + }, + ], + docsUrl: 'https://docs.oracle.com/en/cloud/saas/applications-common/26b/farca/Quick_Start.html', + helpText: + 'The application URL is validated when saved. Oracle authenticates the integration user on the first product request.', + }, } /** diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/oracle-fusion.test.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/oracle-fusion.test.ts new file mode 100644 index 00000000000..4b25b6d89d8 --- /dev/null +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/oracle-fusion.test.ts @@ -0,0 +1,94 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' +import { mintOracleFusionServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/oracle-fusion' + +const FIELDS = { + orgId: 'https://vision.fa.us2.oraclecloud.com', + clientId: 'integration-user', + clientSecret: 'password-with-symbols-!@#', +} + +describe('mintOracleFusionServiceAccountToken', () => { + it('derives an opaque Basic credential locally with a five-minute lifetime', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch') + + await expect(mintOracleFusionServiceAccountToken(FIELDS)).resolves.toEqual({ + instanceUrl: FIELDS.orgId, + accessToken: Buffer.from(`${FIELDS.clientId}:${FIELDS.clientSecret}`, 'utf8').toString( + 'base64' + ), + expiresInSeconds: 300, + identity: { + displayName: 'Oracle Fusion vision', + principal: null, + auditMetadata: { oracleFusionApplicationOrigin: FIELDS.orgId }, + storedMetadata: { applicationOrigin: FIELDS.orgId }, + }, + }) + expect(fetchSpy).not.toHaveBeenCalled() + fetchSpy.mockRestore() + }) + + it('normalizes the origin and omits connect-time identity during resolution', async () => { + await expect( + mintOracleFusionServiceAccountToken( + { ...FIELDS, orgId: ' HTTPS://VISION.FA.OCS.ORACLECLOUD.COM/ ' }, + { skipIdentity: true } + ) + ).resolves.toEqual({ + instanceUrl: 'https://vision.fa.ocs.oraclecloud.com', + accessToken: Buffer.from(`${FIELDS.clientId}:${FIELDS.clientSecret}`, 'utf8').toString( + 'base64' + ), + expiresInSeconds: 300, + }) + }) + + it.each([ + 'http://vision.fa.us2.oraclecloud.com', + 'https://vision.fa.us2.oraclecloud.com/path', + 'https://vision.fa.us2.oraclecloud.com/path/..', + 'https://vision.fa.us2.oraclecloud.com/%2e%2e/', + 'https://vision.fa.us2.oraclecloud.com:443', + 'https://user:password@vision.fa.us2.oraclecloud.com', + 'https://vision.fa.us2.oraclecloud.com?tenant=other', + 'https://vision.fa.us2.oraclecloud.com#fragment', + 'https://vision.fa.us2.oraclecloud.com.evil.example', + 'https://vanity.example.com', + ])('rejects the unsafe application URL %j without a network probe', async (orgId) => { + const fetchSpy = vi.spyOn(globalThis, 'fetch') + await expect(mintOracleFusionServiceAccountToken({ ...FIELDS, orgId })).rejects.toMatchObject({ + code: 'site_not_found', + status: 400, + }) + expect(fetchSpy).not.toHaveBeenCalled() + fetchSpy.mockRestore() + }) + + it.each([ + ['', FIELDS.clientSecret], + ['user:name', FIELDS.clientSecret], + ['user\nname', FIELDS.clientSecret], + ['u'.repeat(256), FIELDS.clientSecret], + [FIELDS.clientId, ''], + [FIELDS.clientId, 'password\n'], + [FIELDS.clientId, 'p'.repeat(1025)], + ])( + 'rejects malformed local credentials without exposing them', + async (clientId, clientSecret) => { + const error = await mintOracleFusionServiceAccountToken({ + ...FIELDS, + clientId, + clientSecret, + }).catch((caught: unknown) => caught) + expect(error).toMatchObject({ code: 'invalid_credentials', status: 400 }) + const serialized = JSON.stringify(error) + if (clientId) expect(serialized).not.toContain(clientId) + if (clientSecret) expect(serialized).not.toContain(clientSecret) + const encoded = Buffer.from(`${clientId}:${clientSecret}`, 'utf8').toString('base64') + if (encoded) expect(serialized).not.toContain(encoded) + } + ) +}) diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/oracle-fusion.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/oracle-fusion.ts new file mode 100644 index 00000000000..a69e1e49f5d --- /dev/null +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/oracle-fusion.ts @@ -0,0 +1,68 @@ +import { normalizeOracleFusionApplicationOrigin } from '@/lib/credentials/client-credential-accounts/descriptors' +import type { + ClientCredentialAccountFields, + ClientCredentialAccountMintOptions, + ClientCredentialAccountMintResult, +} from '@/lib/credentials/client-credential-accounts/server' +import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' + +const BASIC_CREDENTIAL_CACHE_TTL_SECONDS = 5 * 60 +const ORACLE_FUSION_CREDENTIAL_STEP = 'oracle_fusion_credential_validation' +const USERNAME_MAX_LENGTH = 255 +const PASSWORD_MAX_LENGTH = 1024 +const CONTROL_CHARACTER = /[\u0000-\u001f\u007f]/ + +function invalidCredential(reason: string): TokenServiceAccountValidationError { + return new TokenServiceAccountValidationError('invalid_credentials', 400, { + step: ORACLE_FUSION_CREDENTIAL_STEP, + reason, + }) +} + +/** + * Resolves locally validated Oracle Basic credentials through the shared + * client-credential minter contract. Oracle does not expose a documented, + * privilege-neutral identity probe, so authentication occurs on first use. + */ +export async function mintOracleFusionServiceAccountToken( + fields: ClientCredentialAccountFields, + options?: ClientCredentialAccountMintOptions +): Promise { + const instanceUrl = normalizeOracleFusionApplicationOrigin(fields.orgId) + if (!instanceUrl) { + throw new TokenServiceAccountValidationError('site_not_found', 400, { + step: ORACLE_FUSION_CREDENTIAL_STEP, + reason: 'Fusion Applications URL must be a canonical Oracle-assigned HTTPS origin', + }) + } + + const username = fields.clientId.trim() + const password = fields.clientSecret + if (!username || username.length > USERNAME_MAX_LENGTH || CONTROL_CHARACTER.test(username)) { + throw invalidCredential('integration username is invalid') + } + if (username.includes(':')) { + throw invalidCredential('integration username must not contain a colon') + } + if (!password || password.length > PASSWORD_MAX_LENGTH || CONTROL_CHARACTER.test(password)) { + throw invalidCredential('password is invalid') + } + + const accessToken = Buffer.from(`${username}:${password}`, 'utf8').toString('base64') + const tenant = new URL(instanceUrl).hostname.split('.')[0] + return { + instanceUrl, + accessToken, + expiresInSeconds: BASIC_CREDENTIAL_CACHE_TTL_SECONDS, + ...(!options?.skipIdentity + ? { + identity: { + displayName: `Oracle Fusion ${tenant}`, + principal: null, + auditMetadata: { oracleFusionApplicationOrigin: instanceUrl }, + storedMetadata: { applicationOrigin: instanceUrl }, + }, + } + : {}), + } +} diff --git a/apps/sim/lib/credentials/client-credential-accounts/server.test.ts b/apps/sim/lib/credentials/client-credential-accounts/server.test.ts index c91df17c197..ddf42a8cdc1 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/server.test.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/server.test.ts @@ -117,4 +117,31 @@ describe('parseClientCredentialAccountSecretBlob', () => { ) ).toThrow(MALFORMED) }) + + it('requires the three reused fields for an Oracle Fusion credential blob', () => { + const oracleBlob = blob({ + providerId: 'oracle-fusion-service-account', + orgId: 'https://vision.fa.us2.oraclecloud.com', + clientId: 'integration-user', + clientSecret: 'password', + }) + expect( + parseClientCredentialAccountSecretBlob(oracleBlob, 'oracle-fusion-service-account') + ).toMatchObject({ + orgId: 'https://vision.fa.us2.oraclecloud.com', + clientId: 'integration-user', + clientSecret: 'password', + }) + + expect(() => + parseClientCredentialAccountSecretBlob( + blob({ + providerId: 'oracle-fusion-service-account', + orgId: 'https://vision.fa.us2.oraclecloud.com', + clientSecret: undefined, + }), + 'oracle-fusion-service-account' + ) + ).toThrow(MALFORMED) + }) }) diff --git a/apps/sim/lib/credentials/client-credential-accounts/server.ts b/apps/sim/lib/credentials/client-credential-accounts/server.ts index 10c829c7861..bbbaaf120b0 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/server.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/server.ts @@ -5,6 +5,7 @@ import { getClientCredentialAccountDescriptor, isClientCredentialAccountProviderId, NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID, + ORACLE_FUSION_SERVICE_ACCOUNT_PROVIDER_ID, partitionClientCredentialFields, SALESFORCE_SERVICE_ACCOUNT_PROVIDER_ID, ZOHO_DESK_SERVICE_ACCOUNT_PROVIDER_ID, @@ -12,6 +13,7 @@ import { } from '@/lib/credentials/client-credential-accounts/descriptors' import { mintBoxServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/box' import { mintNetSuiteServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/netsuite' +import { mintOracleFusionServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/oracle-fusion' import { mintSalesforceServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/salesforce' import { mintZohoDeskServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/zoho-desk' import { mintZoomServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/zoom' @@ -130,6 +132,7 @@ const CLIENT_CREDENTIAL_ACCOUNT_MINTERS: Record< [SALESFORCE_SERVICE_ACCOUNT_PROVIDER_ID]: mintSalesforceServiceAccountToken, [ZOHO_DESK_SERVICE_ACCOUNT_PROVIDER_ID]: mintZohoDeskServiceAccountToken, [NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID]: mintNetSuiteServiceAccountToken, + [ORACLE_FUSION_SERVICE_ACCOUNT_PROVIDER_ID]: mintOracleFusionServiceAccountToken, } export function getClientCredentialAccountMinter( 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..0f402a4d9da 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('oracle-fusion-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('oracle-fusion-service-account')).toBeNull() }) }) @@ -52,6 +54,7 @@ describe('getServiceAccountConnectNoun', () => { it('names the client-credential secret', () => { expect(getServiceAccountConnectNoun('zoom-service-account')).toBe('server-to-server app') expect(getServiceAccountConnectNoun('netsuite-service-account')).toBe('OAuth certificate') + expect(getServiceAccountConnectNoun('oracle-fusion-service-account')).toBe('integration user') }) it('calls a custom Slack bot a custom bot', () => { diff --git a/apps/sim/lib/credentials/service-account-secret.test.ts b/apps/sim/lib/credentials/service-account-secret.test.ts index fd11efb6b33..5dd00952192 100644 --- a/apps/sim/lib/credentials/service-account-secret.test.ts +++ b/apps/sim/lib/credentials/service-account-secret.test.ts @@ -43,7 +43,8 @@ vi.mock('@/lib/credentials/client-credential-accounts/server', () => ({ getClientCredentialAccountMinter: (providerId: string) => providerId === 'zoom-service-account' || providerId === 'box-service-account' || - providerId === 'netsuite-service-account' + providerId === 'netsuite-service-account' || + providerId === 'oracle-fusion-service-account' ? mockClientCredentialMinter : undefined, })) @@ -261,6 +262,62 @@ describe('verifyAndBuildServiceAccountSecret', () => { }) }) + it('encrypts only the Oracle Fusion fields and captures no unverified principal', async () => { + mockClientCredentialMinter.mockResolvedValue({ + accessToken: 'opaque-basic', + expiresInSeconds: 300, + instanceUrl: 'https://vision.fa.us2.oraclecloud.com', + identity: { + displayName: 'Oracle Fusion vision', + principal: null, + auditMetadata: { + oracleFusionApplicationOrigin: 'https://vision.fa.us2.oraclecloud.com', + }, + storedMetadata: { applicationOrigin: 'https://vision.fa.us2.oraclecloud.com' }, + }, + }) + + const result = await verifyAndBuildServiceAccountSecret('oracle-fusion-service-account', { + orgId: ' https://vision.fa.us2.oraclecloud.com/ ', + clientId: ' integration-user ', + clientSecret: ' password ', + certificateId: 'discard-me', + authMethod: 'discard-me', + privateKey: 'discard-me', + username: 'discard-me', + }) + + expect(mockClientCredentialMinter).toHaveBeenCalledWith({ + orgId: 'https://vision.fa.us2.oraclecloud.com/', + clientId: 'integration-user', + clientSecret: 'password', + certificateId: undefined, + dataCenter: undefined, + authMethod: undefined, + privateKey: undefined, + username: undefined, + }) + expect(result).toMatchObject({ + displayName: 'Oracle Fusion vision', + principal: null, + auditMetadata: { + oracleFusionApplicationOrigin: 'https://vision.fa.us2.oraclecloud.com', + principalKind: 'none', + }, + }) + expect(JSON.parse(result.encryptedServiceAccountKey)).toEqual({ + type: 'client_credential_account', + providerId: 'oracle-fusion-service-account', + clientId: 'integration-user', + clientSecret: 'password', + orgId: 'https://vision.fa.us2.oraclecloud.com/', + metadata: { + applicationOrigin: 'https://vision.fa.us2.oraclecloud.com', + principalKind: 'none', + }, + }) + }) + it('throws when client-credential required fields are missing, without minting', async () => { await expect( verifyAndBuildServiceAccountSecret('zoom-service-account', { diff --git a/apps/sim/lib/internal/oracle-fusion/client.test.ts b/apps/sim/lib/internal/oracle-fusion/client.test.ts new file mode 100644 index 00000000000..d526b96d23e --- /dev/null +++ b/apps/sim/lib/internal/oracle-fusion/client.test.ts @@ -0,0 +1,490 @@ +/** + * @vitest-environment node + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockSecureFetch, mockSleep, mockValidateUrl } = vi.hoisted(() => ({ + mockSecureFetch: vi.fn(), + mockSleep: vi.fn(), + mockValidateUrl: vi.fn(), +})) + +vi.mock('@/lib/core/security/input-validation.server', () => ({ + secureFetchWithPinnedIP: mockSecureFetch, + validateUrlWithDNS: mockValidateUrl, +})) +vi.mock('@sim/utils/helpers', () => ({ interruptibleSleep: mockSleep })) + +import { createTimeoutAbortController } from '@/lib/core/execution-limits' +import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' +import { + type OracleFusionRequest, + type OracleFusionResolvedCredential, + requestOracleFusionEmpty, + requestOracleFusionJson, +} from '@/lib/internal/oracle-fusion/client' +import { OracleFusionProviderError } from '@/lib/internal/oracle-fusion/errors' +import { oracleFusionExactInteger } from '@/lib/internal/oracle-fusion/request-body' + +const ORIGIN = 'https://vision.fa.us2.oraclecloud.com' +const BASIC = Buffer.from('integration-user:password').toString('base64') +const CREDENTIAL: OracleFusionResolvedCredential = { + instanceUrl: ORIGIN, + accessToken: BASIC, +} + +function response( + status: number, + body: string, + headers: Record = {}, + stream: ReadableStream | null = null +) { + return { + ok: status >= 200 && status < 300, + status, + statusText: '', + headers: { get: (name: string) => headers[name.toLowerCase()] ?? null }, + body: stream, + text: vi.fn(async () => body), + json: vi.fn(async () => JSON.parse(body)), + arrayBuffer: vi.fn(async () => new TextEncoder().encode(body).buffer), + } +} + +function getRequest(): OracleFusionRequest { + return { address: { family: 'hcm', relativePath: 'workers' } } +} + +describe('Oracle Fusion client', () => { + beforeEach(() => { + vi.clearAllMocks() + mockValidateUrl.mockResolvedValue({ + isValid: true, + resolvedIP: '203.0.113.10', + originalHostname: 'vision.fa.us2.oraclecloud.com', + }) + mockSleep.mockResolvedValue(undefined) + mockSecureFetch.mockResolvedValue(response(200, '{"items":[]}')) + }) + + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it.each([ + ['hcm', '/hcmRestApi/resources/11.13.18.05/workers'], + ['fscm', '/fscmRestApi/resources/11.13.18.05/invoices'], + ['crm', '/crmRestApi/resources/11.13.18.05/opportunities'], + ] as const)( + 'pins the %s API family, headers, DNS result, and GET method', + async (family, path) => { + await expect( + requestOracleFusionJson(CREDENTIAL, { + address: { family, relativePath: path.split('/').at(-1)! }, + query: { + q: 'Name="A B"', + limit: 25, + expand: undefined, + onlyData: true, + }, + }) + ).resolves.toEqual({ items: [] }) + + expect(mockValidateUrl).toHaveBeenCalledWith( + ORIGIN, + 'Fusion Applications URL', + 'configuredEndpoint', + { logDetails: false } + ) + const [url, resolvedIP, init] = mockSecureFetch.mock.calls[0] + const parsedUrl = new URL(url) + expect(parsedUrl.origin + parsedUrl.pathname).toBe(`${ORIGIN}${path}`) + expect(parsedUrl.searchParams.get('q')).toBe('Name="A B"') + expect(parsedUrl.searchParams.get('limit')).toBe('25') + expect(parsedUrl.searchParams.get('onlyData')).toBe('true') + expect(parsedUrl.searchParams.has('expand')).toBe(false) + expect(resolvedIP).toBe('203.0.113.10') + expect(init).toMatchObject({ + profile: 'configuredEndpoint', + method: 'GET', + timeout: 30_000, + maxRedirects: 0, + maxResponseBytes: 5 * 1024 * 1024, + logUrlValidationDetails: false, + headers: { + Accept: 'application/json', + Authorization: `Basic ${BASIC}`, + 'REST-Framework-Version': '9', + }, + }) + expect(init.signal).toBeInstanceOf(AbortSignal) + } + ) + + it.each([ + ['POST', 'application/json'], + ['PATCH', 'application/vnd.oracle.adf.resourceitem+json'], + ['PUT', 'application/vnd.oracle.adf.action+json'], + ] as const)( + 'sends a bounded %s JSON request with a closed media type', + async (method, mediaType) => { + await expect( + requestOracleFusionJson(CREDENTIAL, { + address: { family: 'fscm', relativePath: 'invoices/42' }, + method, + mediaType, + body: { amount: 12, approved: true }, + operationHeaders: { + effectiveOf: 'RangeMode=UPDATE', + ifMatch: 'etag-value', + upsertMode: false, + }, + }) + ).resolves.toEqual({ items: [] }) + + const init = mockSecureFetch.mock.calls[0][2] + expect(init).toMatchObject({ + method, + body: '{"amount":12,"approved":true}', + headers: { + Accept: 'application/json', + Authorization: `Basic ${BASIC}`, + 'Content-Type': mediaType, + 'Effective-Of': 'RangeMode=UPDATE', + 'If-Match': 'etag-value', + 'REST-Framework-Version': '9', + 'Upsert-Mode': 'false', + }, + }) + } + ) + + it('sends an exact numeric identifier in the mutation body without rounding or quoting', async () => { + await requestOracleFusionJson(CREDENTIAL, { + address: { family: 'fscm', relativePath: 'projectBudgets' }, + method: 'POST', + mediaType: 'application/json', + body: { ProjectId: oracleFusionExactInteger('999999999999999999') }, + }) + + expect(mockSecureFetch).toHaveBeenCalledTimes(1) + expect(mockSecureFetch.mock.calls[0][2]).toMatchObject({ + method: 'POST', + body: '{"ProjectId":999999999999999999}', + headers: { 'REST-Framework-Version': '9', 'Content-Type': 'application/json' }, + }) + }) + + it('sends DELETE without a body and consumes an empty-mode success without JSON parsing', async () => { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('ignored')) + controller.close() + }, + }) + const success = response(204, 'not-json', {}, stream) + mockSecureFetch.mockResolvedValueOnce(success) + + await expect( + requestOracleFusionEmpty(CREDENTIAL, { + address: { family: 'crm', relativePath: 'opportunities/42' }, + method: 'DELETE', + operationHeaders: { ifMatch: '*' }, + }) + ).resolves.toBeUndefined() + + expect(mockSecureFetch.mock.calls[0][2]).toMatchObject({ + method: 'DELETE', + headers: { 'If-Match': '*' }, + }) + expect(mockSecureFetch.mock.calls[0][2]).not.toHaveProperty('body') + expect(success.text).not.toHaveBeenCalled() + }) + + it.each([ + '', + '/workers', + '//evil.example/workers', + 'https://evil.example/workers', + 'workers/../users', + 'workers/./users', + 'workers\\users', + 'workers?limit=1', + 'workers#fragment', + 'workers/%2e%2e/users', + 'workers/%2Fusers', + 'workers/%5cusers', + ])('rejects the unsafe relative path %j before DNS or fetch', async (path) => { + await expect( + requestOracleFusionJson(CREDENTIAL, { + address: { family: 'hcm', relativePath: path }, + }) + ).rejects.toThrow(/resource path/) + expect(mockValidateUrl).not.toHaveBeenCalled() + expect(mockSecureFetch).not.toHaveBeenCalled() + }) + + it('accepts the URL-safe encoding produced for an opaque key containing a percent sign', async () => { + await expect( + requestOracleFusionJson(CREDENTIAL, { + address: { family: 'hcm', relativePath: 'workers/key%252Fpart' }, + }) + ).resolves.toEqual({ items: [] }) + expect(new URL(mockSecureFetch.mock.calls[0][0]).pathname).toMatch(/\/workers\/key%252Fpart$/) + }) + + it('rejects unsupported methods, body modes, media types, and operation headers locally', async () => { + const invalidRequests: OracleFusionRequest[] = [ + { + ...getRequest(), + method: 'OPTIONS', + } as unknown as OracleFusionRequest, + { + ...getRequest(), + method: 'GET', + body: {}, + } as unknown as OracleFusionRequest, + { + ...getRequest(), + method: 'POST', + mediaType: 'application/json', + } as unknown as OracleFusionRequest, + { + ...getRequest(), + method: 'POST', + mediaType: 'text/plain', + body: {}, + } as unknown as OracleFusionRequest, + { + ...getRequest(), + operationHeaders: { authorization: 'secret' }, + } as unknown as OracleFusionRequest, + ] + + for (const request of invalidRequests) { + await expect(requestOracleFusionJson(CREDENTIAL, request)).rejects.toThrow(/Oracle Fusion/) + } + expect(mockValidateUrl).not.toHaveBeenCalled() + expect(mockSecureFetch).not.toHaveBeenCalled() + }) + + it.each(['', ' ', ' untrimmed', 'line\nbreak', 'x'.repeat(2_049)])( + 'rejects the unsafe operation header value %j locally', + async (value) => { + await expect( + requestOracleFusionJson(CREDENTIAL, { + ...getRequest(), + operationHeaders: { ifMatch: value }, + }) + ).rejects.toThrow('header value is invalid') + expect(mockSecureFetch).not.toHaveBeenCalled() + } + ) + + it('rejects a non-public DNS result before fetching', async () => { + mockValidateUrl.mockResolvedValueOnce({ + isValid: false, + error: 'private address', + }) + await expect(requestOracleFusionJson(CREDENTIAL, getRequest())).rejects.toThrow( + 'not a public endpoint' + ) + expect(mockSecureFetch).not.toHaveBeenCalled() + }) + + it.each([429, 503, 504])( + 'retries GET once for HTTP %s and caps Retry-After at five seconds', + async (status) => { + mockSecureFetch + .mockResolvedValueOnce(response(status, 'secret provider body', { 'retry-after': '90' })) + .mockResolvedValueOnce(response(200, '{"ok":true}')) + + await expect(requestOracleFusionJson(CREDENTIAL, getRequest())).resolves.toEqual({ ok: true }) + expect(mockSecureFetch).toHaveBeenCalledTimes(2) + expect(mockSleep).toHaveBeenCalledTimes(1) + expect(mockSleep).toHaveBeenCalledWith(5_000, undefined) + } + ) + + it('stops after one GET retry', async () => { + mockSecureFetch + .mockResolvedValueOnce(response(429, 'secret provider body')) + .mockResolvedValueOnce(response(503, 'secret provider body')) + + await expect(requestOracleFusionJson(CREDENTIAL, getRequest())).rejects.toMatchObject({ + status: 503, + }) + expect(mockSecureFetch).toHaveBeenCalledTimes(2) + expect(mockSleep).toHaveBeenCalledTimes(1) + }) + + it('does not retry when the caller deadline cannot fit the delay, attempt, and reserve', async () => { + const execution = createTimeoutAbortController(39_000) + mockSecureFetch.mockResolvedValueOnce( + response(429, 'secret provider body', { 'retry-after': '5' }) + ) + try { + await expect( + requestOracleFusionJson(CREDENTIAL, getRequest(), execution.signal) + ).rejects.toMatchObject({ status: 429 }) + } finally { + execution.cleanup() + } + expect(mockSecureFetch).toHaveBeenCalledTimes(1) + expect(mockSleep).not.toHaveBeenCalled() + }) + + it.each(['POST', 'PATCH', 'PUT'] as const)('never retries %s mutations', async (method) => { + mockSecureFetch.mockResolvedValueOnce(response(503, 'secret provider body')) + await expect( + requestOracleFusionJson(CREDENTIAL, { + ...getRequest(), + method, + mediaType: 'application/json', + body: {}, + }) + ).rejects.toMatchObject({ status: 503 }) + expect(mockSecureFetch).toHaveBeenCalledTimes(1) + expect(mockSleep).not.toHaveBeenCalled() + }) + + it('never retries DELETE', async () => { + mockSecureFetch.mockResolvedValueOnce(response(504, 'secret provider body')) + await expect( + requestOracleFusionEmpty(CREDENTIAL, { + ...getRequest(), + method: 'DELETE', + }) + ).rejects.toMatchObject({ status: 504 }) + expect(mockSecureFetch).toHaveBeenCalledTimes(1) + expect(mockSleep).not.toHaveBeenCalled() + }) + + it('enforces the attempt deadline through response body consumption', async () => { + vi.useFakeTimers() + const success = response(200, '') + success.text.mockImplementationOnce(() => new Promise(() => {})) + mockSecureFetch.mockResolvedValueOnce(success) + + const pending = expect(requestOracleFusionJson(CREDENTIAL, getRequest())).rejects.toMatchObject( + { + message: 'Oracle Fusion request timed out', + status: 504, + } + ) + await vi.advanceTimersByTimeAsync(30_000) + await pending + }) + + it('rejects redirects without exposing their location or body', async () => { + mockSecureFetch.mockResolvedValueOnce( + response(302, `redirect ${BASIC}`, { location: 'https://evil.example' }) + ) + const error = await requestOracleFusionJson(CREDENTIAL, getRequest()).catch( + (caught: unknown) => caught + ) + expect(error).toMatchObject({ status: 302 }) + expect(String(error)).not.toContain('evil.example') + expect(String(error)).not.toContain(BASIC) + }) + + it('classifies redirects rejected by the pinned transport without exposing details', async () => { + mockSecureFetch.mockRejectedValueOnce(new Error('Too many redirects (max: 0)')) + const error = await requestOracleFusionJson(CREDENTIAL, getRequest()).catch( + (caught: unknown) => caught + ) + expect(error).toMatchObject({ + message: 'Oracle Fusion returned a redirect', + status: 502, + }) + expect(String(error)).not.toContain(ORIGIN) + expect(String(error)).not.toContain(BASIC) + }) + + it('preserves unsafe integral JSON tokens as decimal strings', async () => { + mockSecureFetch.mockResolvedValueOnce( + response( + 200, + '{"id":9007199254740993,"negative":-9007199254740993,"zeroFraction":9007199254740993.0,"exponent":9.007199254740993e15,"hugeExponent":1e999,"safe":9007199254740991,"decimal":9007199254740993.5}' + ) + ) + await expect(requestOracleFusionJson(CREDENTIAL, getRequest())).resolves.toEqual({ + id: '9007199254740993', + negative: '-9007199254740993', + zeroFraction: '9007199254740993.0', + exponent: '9.007199254740993e15', + hugeExponent: '1e999', + safe: 9007199254740991, + decimal: 9007199254740994, + }) + }) + + it('returns fixed provider errors without credential or body reflection', async () => { + const password = 'provider-reflected-password' + const accessToken = Buffer.from(`integration-user:${password}`).toString('base64') + mockSecureFetch.mockResolvedValueOnce( + response(401, `integration-user ${password} ${accessToken}`) + ) + const error = await requestOracleFusionJson({ ...CREDENTIAL, accessToken }, getRequest()).catch( + (caught: unknown) => caught + ) + expect(error).toBeInstanceOf(OracleFusionProviderError) + expect(error).toMatchObject({ + message: 'Oracle Fusion authentication failed', + status: 401, + }) + expect(String(error)).not.toContain('integration-user') + expect(String(error)).not.toContain(password) + expect(String(error)).not.toContain(accessToken) + }) + + it('classifies transport timeout, response-limit, and malformed JSON failures', async () => { + mockSecureFetch.mockRejectedValueOnce(new Error('Request timed out after 30000ms')) + await expect(requestOracleFusionJson(CREDENTIAL, getRequest())).rejects.toMatchObject({ + message: 'Oracle Fusion request timed out', + status: 504, + }) + + mockSecureFetch.mockRejectedValueOnce( + new PayloadSizeLimitError({ + label: 'response', + maxBytes: 5 * 1024 * 1024, + }) + ) + await expect(requestOracleFusionJson(CREDENTIAL, getRequest())).rejects.toMatchObject({ + message: 'Oracle Fusion response exceeded 5 MiB', + status: 502, + }) + + mockSecureFetch.mockResolvedValueOnce(response(200, 'not-json')) + await expect(requestOracleFusionJson(CREDENTIAL, getRequest())).rejects.toMatchObject({ + message: 'Oracle Fusion returned malformed JSON', + status: 502, + }) + }) + + it('preserves caller aborts and never starts the request', async () => { + const controller = new AbortController() + const reason = new DOMException('cancelled', 'AbortError') + controller.abort(reason) + await expect(requestOracleFusionJson(CREDENTIAL, getRequest(), controller.signal)).rejects.toBe( + reason + ) + expect(mockValidateUrl).not.toHaveBeenCalled() + expect(mockSecureFetch).not.toHaveBeenCalled() + }) + + it('rejects malformed Basic material and non-finite query values locally', async () => { + await expect( + requestOracleFusionJson({ ...CREDENTIAL, accessToken: 'not basic\r\n' }, getRequest()) + ).rejects.toThrow('credential is malformed') + await expect( + requestOracleFusionJson(CREDENTIAL, { + address: { family: 'hcm', relativePath: 'workers' }, + query: { limit: Number.POSITIVE_INFINITY }, + }) + ).rejects.toThrow('query values must be finite') + expect(mockValidateUrl).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/internal/oracle-fusion/client.ts b/apps/sim/lib/internal/oracle-fusion/client.ts new file mode 100644 index 00000000000..6ea36ec72c3 --- /dev/null +++ b/apps/sim/lib/internal/oracle-fusion/client.ts @@ -0,0 +1,405 @@ +import { interruptibleSleep } from '@sim/utils/helpers' +import { backoffWithJitter, parseRetryAfter } from '@sim/utils/retry' +import { createTimeoutAbortController, getRemainingExecutionMs } from '@/lib/core/execution-limits' +import { + type SecureFetchResponse, + secureFetchWithPinnedIP, + validateUrlWithDNS, +} from '@/lib/core/security/input-validation.server' +import { consumeOrCancelBody, isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' +import { normalizeOracleFusionApplicationOrigin } from '@/lib/credentials/client-credential-accounts/descriptors' +import { OracleFusionProviderError } from '@/lib/internal/oracle-fusion/errors' +import { isOracleFusionIntegralJsonNumberToken } from '@/lib/internal/oracle-fusion/identifiers' +import { + buildOracleFusionResourcePath, + type OracleFusionResourceAddress, +} from '@/lib/internal/oracle-fusion/paths' +import { serializeOracleFusionJsonBody } from '@/lib/internal/oracle-fusion/request-body' + +const REQUEST_TIMEOUT_MS = 30_000 +const RETRY_AFTER_MAX_MS = 5_000 +const RETRY_RESERVE_MS = 5_000 +const RESPONSE_MAX_BYTES = 5 * 1024 * 1024 +const MAX_GET_RETRIES = 1 +const TRANSIENT_STATUSES = new Set([429, 503, 504]) +const METHODS = new Set(['GET', 'POST', 'PATCH', 'PUT', 'DELETE']) +const MEDIA_TYPES = new Set([ + 'application/json', + 'application/vnd.oracle.adf.resourceitem+json', + 'application/vnd.oracle.adf.action+json', +]) +const OPERATION_HEADER_KEYS = new Set(['effectiveOf', 'ifMatch', 'upsertMode']) +const HEADER_VALUE_MAX_LENGTH = 2_048 +const HEADER_CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/ +const CANONICAL_BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/ + +interface JsonParseContext { + source?: string +} + +type JsonParseWithSource = ( + text: string, + reviver: (this: unknown, key: string, value: unknown, context?: JsonParseContext) => unknown +) => unknown + +const jsonParseWithSource = JSON.parse as JsonParseWithSource + +export interface OracleFusionResolvedCredential { + instanceUrl: string + accessToken: string +} + +export type OracleFusionMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE' + +export type OracleFusionMediaType = + | 'application/json' + | 'application/vnd.oracle.adf.resourceitem+json' + | 'application/vnd.oracle.adf.action+json' + +export interface OracleFusionOperationHeaders { + effectiveOf?: string + ifMatch?: string + upsertMode?: boolean +} + +interface OracleFusionRequestBase { + address: OracleFusionResourceAddress + query?: Record + operationHeaders?: OracleFusionOperationHeaders +} + +export type OracleFusionRequest = OracleFusionRequestBase & + ( + | { method?: 'GET'; body?: never; mediaType?: never } + | { method: 'DELETE'; body?: never; mediaType?: never } + | { + method: 'POST' | 'PATCH' | 'PUT' + body: unknown + mediaType: OracleFusionMediaType + } + ) + +interface PreparedRequest { + method: OracleFusionMethod + headers: Record + body?: string +} + +function validateBasicCredential(accessToken: string): void { + if ( + !accessToken || + accessToken.length > 4096 || + accessToken.length % 4 !== 0 || + !CANONICAL_BASE64.test(accessToken) + ) { + throw new Error('Oracle Fusion credential is malformed') + } +} + +function buildRequestUrl(origin: string, request: OracleFusionRequest): string { + const resourcePath = buildOracleFusionResourcePath(request.address) + const url = new URL(`${origin}${resourcePath}`) + for (const [key, value] of Object.entries(request.query ?? {})) { + if (value === undefined) continue + if (typeof value === 'number' && !Number.isFinite(value)) { + throw new Error('Oracle Fusion query values must be finite') + } + url.searchParams.set(key, String(value)) + } + if (url.origin !== origin || url.pathname !== resourcePath) { + throw new Error('Oracle Fusion request must remain on the credential-bound API root') + } + return url.toString() +} + +function parseOracleFusionJson(body: string): unknown { + return jsonParseWithSource(body, (_key, value, context) => { + if (typeof value !== 'number' || Number.isSafeInteger(value)) return value + const source = context?.source + return source && isOracleFusionIntegralJsonNumberToken(source) ? source : value + }) +} + +function validateHeaderValue(value: unknown): string { + if (typeof value !== 'string') { + throw new Error('Oracle Fusion operation header values must be strings') + } + const normalized = value.trim() + if ( + !normalized || + normalized !== value || + normalized.length > HEADER_VALUE_MAX_LENGTH || + HEADER_CONTROL_CHARACTERS.test(normalized) + ) { + throw new Error('Oracle Fusion operation header value is invalid') + } + return normalized +} + +function appendOperationHeaders( + target: Record, + operationHeaders: OracleFusionOperationHeaders | undefined +): void { + if (operationHeaders === undefined) return + if ( + operationHeaders === null || + typeof operationHeaders !== 'object' || + Array.isArray(operationHeaders) || + (Object.getPrototypeOf(operationHeaders) !== Object.prototype && + Object.getPrototypeOf(operationHeaders) !== null) + ) { + throw new Error('Oracle Fusion operation headers must be a plain object') + } + for (const key of Reflect.ownKeys(operationHeaders)) { + if (typeof key !== 'string' || !OPERATION_HEADER_KEYS.has(key)) { + throw new Error('Oracle Fusion operation header is not supported') + } + const descriptor = Object.getOwnPropertyDescriptor(operationHeaders, key) + if (!descriptor || descriptor.get || descriptor.set) { + throw new Error('Oracle Fusion operation header is not supported') + } + } + if (operationHeaders.effectiveOf !== undefined) { + target['Effective-Of'] = validateHeaderValue(operationHeaders.effectiveOf) + } + if (operationHeaders.ifMatch !== undefined) { + target['If-Match'] = validateHeaderValue(operationHeaders.ifMatch) + } + if (operationHeaders.upsertMode !== undefined) { + if (typeof operationHeaders.upsertMode !== 'boolean') { + throw new Error('Oracle Fusion Upsert-Mode must be boolean') + } + target['Upsert-Mode'] = String(operationHeaders.upsertMode) + } +} + +function prepareRequest(accessToken: string, request: OracleFusionRequest): PreparedRequest { + const method = request.method ?? 'GET' + if (!METHODS.has(method)) throw new Error('Oracle Fusion request method is not supported') + + const headers: Record = { + Accept: 'application/json', + Authorization: `Basic ${accessToken}`, + 'REST-Framework-Version': '9', + } + appendOperationHeaders(headers, request.operationHeaders) + + const hasBody = 'body' in request && request.body !== undefined + if (method === 'GET' || method === 'DELETE') { + if (hasBody || 'mediaType' in request) { + throw new Error(`Oracle Fusion ${method} requests must not include a body`) + } + return { method, headers } + } + + if (!hasBody) throw new Error(`Oracle Fusion ${method} requests require a JSON body`) + if (!MEDIA_TYPES.has(request.mediaType)) { + throw new Error('Oracle Fusion request media type is not supported') + } + headers['Content-Type'] = request.mediaType + return { method, headers, body: serializeOracleFusionJsonBody(request.body) } +} + +function retryDelay(attempt: number, retryAfterMs: number | null): number { + return backoffWithJitter(attempt + 1, retryAfterMs, { + baseMs: 250, + maxMs: RETRY_AFTER_MAX_MS, + }) +} + +async function waitForRetry(delay: number, signal?: AbortSignal): Promise { + await interruptibleSleep(delay, signal) + signal?.throwIfAborted() +} + +function hasTimeForRetry(delay: number, signal?: AbortSignal): boolean { + const remaining = getRemainingExecutionMs(signal) + return remaining === undefined || remaining >= delay + REQUEST_TIMEOUT_MS + RETRY_RESERVE_MS +} + +async function waitWithSignal(promise: Promise, signal: AbortSignal): Promise { + signal.throwIfAborted() + return new Promise((resolve, reject) => { + const cleanup = () => signal.removeEventListener('abort', onAbort) + const onAbort = () => { + cleanup() + reject(signal.reason ?? new DOMException('user', 'AbortError')) + } + signal.addEventListener('abort', onAbort, { once: true }) + promise.then( + (value) => { + cleanup() + resolve(value) + }, + (error: unknown) => { + cleanup() + reject(error) + } + ) + }) +} + +async function fetchAttempt( + url: string, + resolvedIP: string, + prepared: PreparedRequest, + signal: AbortSignal +): Promise { + return waitWithSignal( + secureFetchWithPinnedIP(url, resolvedIP, { + profile: 'configuredEndpoint', + method: prepared.method, + headers: prepared.headers, + ...(prepared.body === undefined ? {} : { body: prepared.body }), + timeout: REQUEST_TIMEOUT_MS, + maxRedirects: 0, + maxResponseBytes: RESPONSE_MAX_BYTES, + signal, + logUrlValidationDetails: false, + }), + signal + ) +} + +function statusMessage(status: number): string { + if (status === 401) return 'Oracle Fusion authentication failed' + if (status === 403) return 'Oracle Fusion denied this request' + if (status === 404) return 'Oracle Fusion resource was not found' + if (status === 429) return 'Oracle Fusion rate limit exceeded' + return `Oracle Fusion request failed with HTTP ${status}` +} + +/** `maxRedirects: 0` rejects a response with Location before returning its status. */ +function isRejectedRedirect(error: unknown): boolean { + return error instanceof Error && error.message === 'Too many redirects (max: 0)' +} + +function mapAttemptError(error: unknown, timedOut: boolean, callerSignal?: AbortSignal): never { + callerSignal?.throwIfAborted() + if (error instanceof OracleFusionProviderError) throw error + if (timedOut) { + throw new OracleFusionProviderError('Oracle Fusion request timed out', 504) + } + if (isRejectedRedirect(error)) { + throw new OracleFusionProviderError('Oracle Fusion returned a redirect', 502) + } + if (isPayloadSizeLimitError(error)) { + throw new OracleFusionProviderError('Oracle Fusion response exceeded 5 MiB', 502) + } + if (error instanceof Error && error.message.includes('timed out')) { + throw new OracleFusionProviderError('Oracle Fusion request timed out', 504) + } + throw new OracleFusionProviderError('Could not reach Oracle Fusion', 502) +} + +async function readJsonResponse( + response: SecureFetchResponse, + signal: AbortSignal +): Promise { + let body: string + try { + body = await waitWithSignal(response.text(), signal) + } catch (error) { + if (isPayloadSizeLimitError(error)) { + throw new OracleFusionProviderError('Oracle Fusion response exceeded 5 MiB', 502) + } + throw error + } + try { + return parseOracleFusionJson(body) + } catch { + throw new OracleFusionProviderError('Oracle Fusion returned malformed JSON', 502) + } +} + +async function consumeResponse(response: SecureFetchResponse, signal: AbortSignal): Promise { + await waitWithSignal(consumeOrCancelBody(response), signal) +} + +async function validateCredentialOrigin(origin: string, signal?: AbortSignal): Promise { + let validation: Awaited> + try { + validation = await validateUrlWithDNS(origin, 'Fusion Applications URL', 'configuredEndpoint', { + logDetails: false, + }) + } catch { + signal?.throwIfAborted() + throw new Error('Oracle Fusion credential application URL could not be validated') + } + signal?.throwIfAborted() + if (!validation.isValid) { + throw new Error('Oracle Fusion credential application URL is not a public endpoint') + } + return validation.resolvedIP +} + +async function requestOracleFusion( + credential: OracleFusionResolvedCredential, + request: OracleFusionRequest, + responseMode: 'json' | 'empty', + signal?: AbortSignal +): Promise { + signal?.throwIfAborted() + const origin = normalizeOracleFusionApplicationOrigin(credential.instanceUrl) + if (!origin) { + throw new Error('Oracle Fusion credential is not bound to a canonical application URL') + } + validateBasicCredential(credential.accessToken) + const prepared = prepareRequest(credential.accessToken, request) + const url = buildRequestUrl(origin, request) + const resolvedIP = await validateCredentialOrigin(origin, signal) + const maxRetries = prepared.method === 'GET' ? MAX_GET_RETRIES : 0 + + for (let attempt = 0; attempt <= maxRetries; attempt += 1) { + signal?.throwIfAborted() + const deadline = createTimeoutAbortController(REQUEST_TIMEOUT_MS, signal) + let delay: number | undefined + try { + const response = await fetchAttempt(url, resolvedIP, prepared, deadline.signal) + if (TRANSIENT_STATUSES.has(response.status) && attempt < maxRetries) { + const retryAfterMs = parseRetryAfter( + response.headers.get('retry-after'), + RETRY_AFTER_MAX_MS + ) + await consumeResponse(response, deadline.signal) + const candidateDelay = retryDelay(attempt, retryAfterMs) + if (hasTimeForRetry(candidateDelay, signal)) delay = candidateDelay + else throw new OracleFusionProviderError(statusMessage(response.status), response.status) + } else if (!response.ok) { + await consumeResponse(response, deadline.signal) + throw new OracleFusionProviderError(statusMessage(response.status), response.status) + } else if (responseMode === 'json') { + return await readJsonResponse(response, deadline.signal) + } else { + await consumeResponse(response, deadline.signal) + return + } + } catch (error) { + mapAttemptError(error, deadline.isTimedOut(), signal) + } finally { + deadline.cleanup() + } + + if (delay !== undefined) await waitForRetry(delay, signal) + } + + throw new OracleFusionProviderError('Oracle Fusion retry limit was exhausted', 502) +} + +/** Executes a bounded request and parses a required JSON success response losslessly. */ +export async function requestOracleFusionJson( + credential: OracleFusionResolvedCredential, + request: OracleFusionRequest, + signal?: AbortSignal +): Promise { + return requestOracleFusion(credential, request, 'json', signal) +} + +/** Executes a bounded request and consumes or cancels its success response body. */ +export async function requestOracleFusionEmpty( + credential: OracleFusionResolvedCredential, + request: OracleFusionRequest, + signal?: AbortSignal +): Promise { + await requestOracleFusion(credential, request, 'empty', signal) +} diff --git a/apps/sim/lib/internal/oracle-fusion/errors.ts b/apps/sim/lib/internal/oracle-fusion/errors.ts new file mode 100644 index 00000000000..c41b905e8dd --- /dev/null +++ b/apps/sim/lib/internal/oracle-fusion/errors.ts @@ -0,0 +1,10 @@ +/** Safe caller-facing failure from an Oracle Fusion product request. */ +export class OracleFusionProviderError extends Error { + constructor( + message: string, + readonly status: number + ) { + super(message) + this.name = 'OracleFusionProviderError' + } +} diff --git a/apps/sim/lib/internal/oracle-fusion/identifiers.test.ts b/apps/sim/lib/internal/oracle-fusion/identifiers.test.ts new file mode 100644 index 00000000000..e366f92ff5d --- /dev/null +++ b/apps/sim/lib/internal/oracle-fusion/identifiers.test.ts @@ -0,0 +1,81 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + isOracleFusionIntegralJsonNumberToken, + normalizeOracleFusionDecimalIdentifier, +} from '@/lib/internal/oracle-fusion/identifiers' + +const OPTIONS = { maxDigits: 128 } + +describe('isOracleFusionIntegralJsonNumberToken', () => { + it.each([ + '9007199254740993', + '-9007199254740993', + '9007199254740993.0', + '9.007199254740993e15', + '1e999', + `9007199254740993${'0'.repeat(100)}e-100`, + ])('recognizes the exact integral token %j', (source) => { + expect(isOracleFusionIntegralJsonNumberToken(source)).toBe(true) + }) + + it.each(['1.25', '1e-1', '1.23e1', 'not-a-number'])('rejects %j as non-integral', (source) => { + expect(isOracleFusionIntegralJsonNumberToken(source)).toBe(false) + }) +}) + +describe('normalizeOracleFusionDecimalIdentifier', () => { + it.each([ + [0, '0'], + [42, '42'], + ['9223372036854775807', '9223372036854775807'], + ['9.223372036854775807e18', '9223372036854775807'], + ['123.000', '123'], + ['1.23e2', '123'], + ['1000e-3', '1'], + ['0.001e3', '1'], + ['0e999999999999999999999999', '0'], + ])('canonicalizes %j to %s', (value, expected) => { + expect(normalizeOracleFusionDecimalIdentifier(value, OPTIONS)).toBe(expected) + }) + + it.each([ + -1, + 1.25, + Number.MAX_SAFE_INTEGER + 1, + Number.POSITIVE_INFINITY, + '-1', + '-0', + '+1', + '01', + '1.25', + '1e-1', + '1e129', + `1${'0'.repeat(128)}`, + '1'.repeat(129), + ])('rejects the non-canonical or out-of-range identifier %j', (value) => { + expect(normalizeOracleFusionDecimalIdentifier(value, OPTIONS)).toBeUndefined() + }) + + it('checks configured limits before expanding exponent notation', () => { + expect( + normalizeOracleFusionDecimalIdentifier('1e63', { maxDigits: 64, maxSourceLength: 64 }) + ).toBe(`1${'0'.repeat(63)}`) + expect( + normalizeOracleFusionDecimalIdentifier('1e64', { maxDigits: 64, maxSourceLength: 64 }) + ).toBeUndefined() + expect(() => + normalizeOracleFusionDecimalIdentifier('1', { maxDigits: 129, maxSourceLength: 128 }) + ).toThrow('limits are invalid') + }) + + it('checks the digit limit after removing an exact fractional suffix', () => { + expect( + normalizeOracleFusionDecimalIdentifier('123456000.000', { + maxDigits: 8, + }) + ).toBeUndefined() + }) +}) diff --git a/apps/sim/lib/internal/oracle-fusion/identifiers.ts b/apps/sim/lib/internal/oracle-fusion/identifiers.ts new file mode 100644 index 00000000000..26be97e8def --- /dev/null +++ b/apps/sim/lib/internal/oracle-fusion/identifiers.ts @@ -0,0 +1,109 @@ +const INTEGRAL_JSON_NUMBER_TOKEN = /^-?(0|[1-9]\d*)(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/ +const NON_NEGATIVE_INTEGRAL_TOKEN = /^(0|[1-9]\d*)(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/ +const DEFAULT_MAX_SOURCE_LENGTH = 128 + +function compareDecimalMagnitudeToInteger(magnitude: string, value: number): number { + const normalizedMagnitude = magnitude.replace(/^0+/, '') || '0' + const integer = String(value) + if (normalizedMagnitude.length !== integer.length) { + return normalizedMagnitude.length < integer.length ? -1 : 1 + } + if (normalizedMagnitude === integer) return 0 + return normalizedMagnitude < integer ? -1 : 1 +} + +function trailingZeroCount(value: string): number { + let count = 0 + for (let index = value.length - 1; index >= 0 && value[index] === '0'; index--) count++ + return count +} + +/** Whether one JSON number token denotes an exact integer without expanding its exponent. */ +export function isOracleFusionIntegralJsonNumberToken(source: string): boolean { + const match = INTEGRAL_JSON_NUMBER_TOKEN.exec(source) + if (!match) return false + const coefficient = `${match[1]}${match[2] ?? ''}` + if (/^0+$/.test(coefficient)) return true + + const fractionDigits = match[2]?.length ?? 0 + const exponentSource = match[3] ?? '0' + const exponentMagnitude = exponentSource.replace(/^[+-]/, '').replace(/^0+/, '') || '0' + const availableTrailingZeros = trailingZeroCount(coefficient) + + if (exponentSource.startsWith('-')) { + if (compareDecimalMagnitudeToInteger(exponentMagnitude, availableTrailingZeros) > 0) { + return false + } + return fractionDigits + Number(exponentMagnitude) <= availableTrailingZeros + } + if (compareDecimalMagnitudeToInteger(exponentMagnitude, fractionDigits) >= 0) return true + return fractionDigits - Number(exponentMagnitude) <= availableTrailingZeros +} + +function parseBoundedExponent(exponentText: string, maximumMagnitude: number): number | undefined { + const negative = exponentText.startsWith('-') + const unsigned = exponentText.replace(/^[+-]/, '').replace(/^0+(?=\d)/, '') + const maximum = String(maximumMagnitude) + if ( + unsigned.length > maximum.length || + (unsigned.length === maximum.length && unsigned > maximum) + ) { + return undefined + } + const magnitude = Number(unsigned) + return negative ? -magnitude : magnitude +} + +export interface OracleFusionDecimalIdentifierOptions { + maxDigits: number + maxSourceLength?: number +} + +/** Canonicalizes one exact non-negative integral identifier without JS-number precision loss. */ +export function normalizeOracleFusionDecimalIdentifier( + value: unknown, + options: OracleFusionDecimalIdentifierOptions +): string | undefined { + const maxSourceLength = options.maxSourceLength ?? DEFAULT_MAX_SOURCE_LENGTH + if ( + !Number.isSafeInteger(options.maxDigits) || + options.maxDigits < 1 || + !Number.isSafeInteger(maxSourceLength) || + maxSourceLength < 1 || + options.maxDigits > maxSourceLength + ) { + throw new Error('Oracle Fusion decimal identifier limits are invalid') + } + if (typeof value === 'number') { + return Number.isSafeInteger(value) && value >= 0 && String(value).length <= options.maxDigits + ? String(value) + : undefined + } + if (typeof value !== 'string' || value.length > maxSourceLength) return undefined + const match = NON_NEGATIVE_INTEGRAL_TOKEN.exec(value) + if (!match) return undefined + + const integer = match[1] + const fraction = match[2] ?? '' + const exponentText = match[3] ?? '0' + const coefficient = `${integer}${fraction}` + if (/^0+$/.test(coefficient)) return '0' + const significantCoefficient = coefficient.replace(/^0+/, '') + const maximumRelevantExponent = Math.max(integer.length, fraction.length + options.maxDigits) + const exponent = parseBoundedExponent(exponentText, maximumRelevantExponent) + if (exponent === undefined) return undefined + + const scale = exponent - fraction.length + if (scale >= 0) { + if (significantCoefficient.length + scale > options.maxDigits) return undefined + return `${significantCoefficient}${'0'.repeat(scale)}` + } + + const fractionalDigits = -scale + if (fractionalDigits > significantCoefficient.length) return undefined + const suffix = significantCoefficient.slice(significantCoefficient.length - fractionalDigits) + if (!/^0*$/.test(suffix)) return undefined + const normalized = + significantCoefficient.slice(0, significantCoefficient.length - fractionalDigits) || '0' + return normalized.length <= options.maxDigits ? normalized : undefined +} diff --git a/apps/sim/lib/internal/oracle-fusion/paths.test.ts b/apps/sim/lib/internal/oracle-fusion/paths.test.ts new file mode 100644 index 00000000000..13ae1a89971 --- /dev/null +++ b/apps/sim/lib/internal/oracle-fusion/paths.test.ts @@ -0,0 +1,50 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { buildOracleFusionResourcePath } from '@/lib/internal/oracle-fusion/paths' + +describe('buildOracleFusionResourcePath', () => { + it.each([ + ['hcm', 'workers', '/hcmRestApi/resources/11.13.18.05/workers'], + ['fscm', 'invoices/123', '/fscmRestApi/resources/11.13.18.05/invoices/123'], + ['crm', 'opportunities', '/crmRestApi/resources/11.13.18.05/opportunities'], + ] as const)('builds the fixed %s resource root', (family, relativePath, expected) => { + expect(buildOracleFusionResourcePath({ family, relativePath })).toBe(expected) + }) + + it('preserves safe URL encoding in an opaque path segment', () => { + expect( + buildOracleFusionResourcePath({ family: 'hcm', relativePath: 'workers/key%252Fpart' }) + ).toBe('/hcmRestApi/resources/11.13.18.05/workers/key%252Fpart') + }) + + it.each([ + '', + ' workers', + 'workers ', + 'workers/bad key', + '/workers', + '//evil.example/workers', + 'https://evil.example/workers', + 'workers//assignments', + 'workers/', + 'workers/../users', + 'workers/./users', + 'workers\\users', + 'workers?limit=1', + 'workers#fragment', + 'workers/%2e%2e/users', + 'workers/%2Fusers', + 'workers/%5cusers', + 'workers/%3Fquery', + 'workers/%23fragment', + 'workers/%00control', + 'workers/%E0%A4%A', + 'workers/\ud800', + ])('rejects the unsafe relative path %j', (relativePath) => { + expect(() => buildOracleFusionResourcePath({ family: 'hcm', relativePath })).toThrow( + /resource path/ + ) + }) +}) diff --git a/apps/sim/lib/internal/oracle-fusion/paths.ts b/apps/sim/lib/internal/oracle-fusion/paths.ts new file mode 100644 index 00000000000..6c791bfefb4 --- /dev/null +++ b/apps/sim/lib/internal/oracle-fusion/paths.ts @@ -0,0 +1,66 @@ +const API_VERSION = '11.13.18.05' +const API_ROOTS = { + hcm: `/hcmRestApi/resources/${API_VERSION}`, + fscm: `/fscmRestApi/resources/${API_VERSION}`, + crm: `/crmRestApi/resources/${API_VERSION}`, +} as const +const ABSOLUTE_PATH = /^[a-z][a-z0-9+.-]*:/i +const UNSAFE_PATH_ENCODING = /%(?:2e|2f|5c|3f|23)/i +const PATH_CONTROL = /[\u0000-\u001f\u007f]/ +const PATH_WHITESPACE = /\s/ + +export type OracleFusionApiFamily = keyof typeof API_ROOTS + +export interface OracleFusionResourceAddress { + family: OracleFusionApiFamily + relativePath: string +} + +function validateRelativePath(relativePath: string): void { + if ( + !relativePath || + relativePath !== relativePath.trim() || + relativePath.startsWith('/') || + ABSOLUTE_PATH.test(relativePath) || + relativePath.includes('\\') || + relativePath.includes('?') || + relativePath.includes('#') || + PATH_CONTROL.test(relativePath) || + PATH_WHITESPACE.test(relativePath) || + UNSAFE_PATH_ENCODING.test(relativePath) + ) { + throw new Error('Oracle Fusion resource path must be a safe relative path') + } + + for (const segment of relativePath.split('/')) { + if (!segment || segment === '.' || segment === '..') { + throw new Error('Oracle Fusion resource path must not contain empty or traversal segments') + } + let decoded: string + try { + decoded = decodeURIComponent(segment) + void encodeURIComponent(decoded) + } catch { + throw new Error('Oracle Fusion resource path contains invalid URL encoding') + } + if ( + decoded === '.' || + decoded === '..' || + decoded.includes('/') || + decoded.includes('\\') || + decoded.includes('?') || + decoded.includes('#') || + PATH_CONTROL.test(decoded) + ) { + throw new Error('Oracle Fusion resource path must be a safe relative path') + } + } +} + +/** Builds one canonical path beneath a fixed Oracle Fusion product API root. */ +export function buildOracleFusionResourcePath(address: OracleFusionResourceAddress): string { + validateRelativePath(address.relativePath) + const root = API_ROOTS[address.family] + if (!root) throw new Error('Oracle Fusion API family is unsupported') + return `${root}/${address.relativePath}` +} diff --git a/apps/sim/lib/internal/oracle-fusion/protocol.test.ts b/apps/sim/lib/internal/oracle-fusion/protocol.test.ts new file mode 100644 index 00000000000..1ae421dfa5f --- /dev/null +++ b/apps/sim/lib/internal/oracle-fusion/protocol.test.ts @@ -0,0 +1,430 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' +import { + encodeOracleFusionPathSegment, + extractOracleFusionOpaqueKey, + parseOracleFusionCollection, + validateOracleFusionSelfLink, +} from '@/lib/internal/oracle-fusion/protocol' + +const ORIGIN = 'https://vision.fa.us2.oraclecloud.com' +const COLLECTION = '/hcmRestApi/resources/11.13.18.05/workers' +const COLLECTION_ADDRESS = { family: 'hcm', relativePath: 'workers' } as const + +describe('parseOracleFusionCollection', () => { + it('projects a valid page and calculates the next offset', () => { + expect( + parseOracleFusionCollection( + { + items: [{ id: 1 }, { id: 2 }], + count: 2, + hasMore: true, + limit: 25, + offset: 50, + totalResults: 80, + }, + (item, index) => ({ ...(item as object), index }) + ) + ).toEqual({ + items: [ + { id: 1, index: 0 }, + { id: 2, index: 1 }, + ], + count: 2, + hasMore: true, + limit: 25, + offset: 50, + totalResults: 80, + nextOffset: 52, + }) + }) + + it('accepts an empty terminal page and returns its current nextOffset', () => { + expect( + parseOracleFusionCollection( + { items: [], count: 0, hasMore: false, limit: 25, offset: 10, totalResults: 5 }, + (item) => item, + { expectedOffset: 10 } + ) + ).toEqual({ + items: [], + count: 0, + hasMore: false, + limit: 25, + offset: 10, + totalResults: 5, + nextOffset: 10, + }) + }) + + it.each([ + { items: [], count: 0, hasMore: false, limit: 25, offset: 5, totalResults: 6 }, + { items: [{}], count: 1, hasMore: false, limit: 25, offset: 5, totalResults: 7 }, + { items: [{}], count: 1, hasMore: true, limit: 25, offset: 5, totalResults: 6 }, + { items: [{}], count: 1, hasMore: false, limit: 25, offset: 4, totalResults: 4 }, + { items: [{}], count: 1, hasMore: true, limit: 25, offset: 5, totalResults: 4 }, + { items: [{}], count: 1, hasMore: false, limit: 25, offset: 5, totalResults: 0 }, + { items: [{}], count: 1, hasMore: true, limit: 25, offset: 5, totalResults: 0 }, + ])('preserves estimated totals independently of pagination metadata %#', (value) => { + expect(parseOracleFusionCollection(value, (item) => item)).toEqual({ + ...value, + nextOffset: value.offset + value.count, + }) + }) + + it.each( + [ + -1, + 0.5, + '1', + null, + true, + {}, + [], + Number.NaN, + Number.POSITIVE_INFINITY, + Number.NEGATIVE_INFINITY, + Number.MAX_SAFE_INTEGER + 1, + ].map((totalResults) => ({ totalResults })) + )('rejects malformed estimated total $totalResults', ({ totalResults }) => { + const parseItem = vi.fn((item) => item) + expect(() => + parseOracleFusionCollection( + { items: [{}], count: 1, hasMore: false, limit: 25, offset: 0, totalResults }, + parseItem + ) + ).toThrow('Oracle collection totalResults must be a non-negative safe integer') + expect(parseItem).not.toHaveBeenCalled() + }) + + it('does not synthesize an omitted total for a nonempty page', () => { + const page = { items: [{}], count: 1, hasMore: true, limit: 25, offset: 5 } + const result = parseOracleFusionCollection(page, (item) => item) + + expect(result).toEqual({ ...page, nextOffset: 6 }) + expect(result).not.toHaveProperty('totalResults') + }) + + it('accepts omitted items only for an unambiguous empty terminal page', () => { + expect( + parseOracleFusionCollection( + { count: 0, hasMore: false, limit: 25, offset: 10 }, + (item) => item, + { expectedOffset: 10, maxItems: 25 } + ) + ).toEqual({ + items: [], + count: 0, + hasMore: false, + limit: 25, + offset: 10, + nextOffset: 10, + }) + }) + + it('validates expected offset and item limits before projection', () => { + const parseItem = vi.fn((item) => item) + const page = { items: [{ id: 1 }], count: 1, hasMore: false, limit: 5, offset: 4 } + expect(() => + parseOracleFusionCollection(page, parseItem, { expectedOffset: 3, maxItems: 5 }) + ).toThrow('requested offset') + expect(() => + parseOracleFusionCollection(page, parseItem, { expectedOffset: 4, maxItems: 0 }) + ).toThrow('item limit') + expect(parseItem).not.toHaveBeenCalled() + }) + + it('does not require the returned limit to equal the caller item cap', () => { + expect( + parseOracleFusionCollection( + { items: [{ id: 1 }], count: 1, hasMore: false, limit: 73, offset: 0 }, + (item) => item, + { expectedOffset: 0, maxItems: 20 } + ) + ).toMatchObject({ limit: 73, count: 1, nextOffset: 1 }) + }) + + it.each([ + [null, 'must be an object'], + [{}, 'count'], + [{ count: 1, hasMore: false, limit: 25, offset: 0 }, 'items must be an array'], + [{ count: 0, hasMore: true, limit: 25, offset: 0 }, 'items must be an array'], + [{ items: [], count: -1, hasMore: false, limit: 25, offset: 0 }, 'count'], + [{ items: [], count: 0, hasMore: 'no', limit: 25, offset: 0 }, 'hasMore'], + [{ items: [{}], count: 0, hasMore: false, limit: 25, offset: 0 }, 'match'], + [{ items: [], count: 0, hasMore: true, limit: 25, offset: 0 }, 'empty page'], + [{ items: [], count: 0, hasMore: false, limit: 0, offset: 0 }, 'positive'], + [ + { items: [{}], count: 1, hasMore: true, limit: 25, offset: Number.MAX_SAFE_INTEGER }, + 'safe integer range', + ], + ])('rejects malformed collection envelope %#', (value, message) => { + expect(() => parseOracleFusionCollection(value, (item) => item)).toThrow(message as string) + }) +}) + +describe.each(['legacy', 'context', 'both'] as const)('Oracle %s self links', (representation) => { + function resource(href: unknown, otherLinks: unknown[] = []): Record { + const links = [{ rel: 'self', href }, ...otherLinks] + return { + ...(representation !== 'context' ? { links } : {}), + ...(representation !== 'legacy' + ? { '@context': { key: 'not-the-resource-key', links } } + : {}), + } + } + + it('accepts exactly one same-origin self link for the expected path', () => { + expect(() => + validateOracleFusionSelfLink(resource(`${ORIGIN}${COLLECTION}/abc`), ORIGIN, { + family: 'hcm', + relativePath: 'workers/abc', + }) + ).not.toThrow() + }) + + it('extracts item keys from a collection without trusting context keys or collection links', () => { + const address = { family: 'fscm', relativePath: 'invoices' } as const + const collectionPath = '/fscmRestApi/resources/11.13.18.05/invoices' + const key = 'invoice:123,installment=2' + const encodedKey = encodeOracleFusionPathSegment(key) + const page = parseOracleFusionCollection( + { + items: [resource(`${ORIGIN}${collectionPath}/${encodedKey}`)], + count: 1, + limit: 25, + offset: 0, + hasMore: false, + links: [{ rel: 'self', href: `${ORIGIN}${collectionPath}` }], + }, + (item) => { + validateOracleFusionSelfLink(item, ORIGIN, { + ...address, + relativePath: `invoices/${encodedKey}`, + }) + return extractOracleFusionOpaqueKey(item, ORIGIN, address) + } + ) + + expect(page.items).toEqual([key]) + }) + + it('continues ignoring unrelated link relations and non-link entries', () => { + const value = resource(`${ORIGIN}${COLLECTION}/abc`, [ + null, + 'not a link', + [], + { rel: 'canonical', href: 'unrelated' }, + ]) + expect(extractOracleFusionOpaqueKey(value, ORIGIN, COLLECTION_ADDRESS)).toBe('abc') + }) + + it.each([ + [{}, 'exactly one'], + [{ links: [] }, 'exactly one'], + [ + resource(`${ORIGIN}${COLLECTION}/abc`, [{ rel: 'self', href: `${ORIGIN}/duplicate` }]), + 'exactly one', + ], + [resource(123), 'malformed'], + [resource('not a URL'), 'malformed'], + [resource(`https://evil.example${COLLECTION}/abc`), 'credential-bound origin'], + [resource(`${ORIGIN}${COLLECTION}/abc?secret=value`), 'credential-bound origin'], + [resource(`${ORIGIN}${COLLECTION}/abc#fragment`), 'credential-bound origin'], + [ + resource(`${ORIGIN.replace('https://', 'https://user:password@')}${COLLECTION}/abc`), + 'credential-bound origin', + ], + [resource(`${ORIGIN}${COLLECTION}/other`), 'requested resource path'], + ])('rejects missing, duplicate, malformed, or unbound self links %#', (value, message) => { + expect(() => + validateOracleFusionSelfLink(value, ORIGIN, { + family: 'hcm', + relativePath: 'workers/abc', + }) + ).toThrow(message as string) + }) + + it('extracts and URL-encodes an opaque key without changing its value', () => { + const key = 'person:123,assignment=456' + const encoded = encodeOracleFusionPathSegment(key) + expect(encoded).toBe('person%3A123%2Cassignment%3D456') + expect( + extractOracleFusionOpaqueKey( + resource(`${ORIGIN}${COLLECTION}/${encoded}`), + ORIGIN, + COLLECTION_ADDRESS + ) + ).toBe(key) + }) + + it('requires spaces in self-link keys to be encoded and preserves their value', () => { + const key = 'person name ' + const encoded = encodeOracleFusionPathSegment(key) + expect(encoded).toBe('person%20name%20') + expect( + extractOracleFusionOpaqueKey( + resource(`${ORIGIN}${COLLECTION}/${encoded}`), + ORIGIN, + COLLECTION_ADDRESS + ) + ).toBe(key) + expect(() => + extractOracleFusionOpaqueKey( + resource(`${ORIGIN}${COLLECTION}/${key}`), + ORIGIN, + COLLECTION_ADDRESS + ) + ).toThrow('Oracle self link is malformed') + }) + + it.each(['', ' ', '.', '..', 'a/b', 'a\\b', 'a?b', 'a#b', 'a\nb', 'x'.repeat(2049)])( + 'rejects the unsafe opaque key %j', + (key) => { + expect(() => encodeOracleFusionPathSegment(key)).toThrow('safe opaque path segment') + } + ) + + it('rejects malformed Unicode without leaking a URI error', () => { + expect(() => encodeOracleFusionPathSegment('\ud800')).toThrow( + 'Oracle resource key contains malformed Unicode' + ) + }) + + it('rejects a self-link href containing malformed Unicode before URL parsing', () => { + expect(() => + extractOracleFusionOpaqueKey( + resource(`${ORIGIN}${COLLECTION}/bad\ud800key`), + ORIGIN, + COLLECTION_ADDRESS + ) + ).toThrow('Oracle self link is malformed') + }) + + it.each(['\t', '\n', '\r'])( + 'rejects a self-link key containing the raw control character %j before URL parsing', + (control) => { + expect(() => + extractOracleFusionOpaqueKey( + resource(`${ORIGIN}${COLLECTION}/bad${control}key`), + ORIGIN, + COLLECTION_ADDRESS + ) + ).toThrow('Oracle self link is malformed') + } + ) + + it.each([ + `${ORIGIN}${COLLECTION}/parent/../abc`, + `${ORIGIN}${COLLECTION}/parent/%2e%2e/abc`, + `${ORIGIN}${COLLECTION}/parent/.%2E/abc`, + `${ORIGIN}${COLLECTION}/parent\\..\\abc`, + ])('rejects a self-link path that URL parsing would normalize %j', (href) => { + expect(() => + validateOracleFusionSelfLink(resource(href), ORIGIN, { + family: 'hcm', + relativePath: 'workers/abc', + }) + ).toThrow('Oracle self link is malformed') + }) + + it.each([ + [`${ORIGIN}/other/abc`, 'collection path'], + [`${ORIGIN}${COLLECTION}/a/b`, 'one opaque key'], + [`${ORIGIN}${COLLECTION}/a%2Fb`, 'one opaque key'], + [`${ORIGIN}${COLLECTION}/a%5Cb`, 'one opaque key'], + [`${ORIGIN}${COLLECTION}/%E0%A4%A`, 'invalid URL encoding'], + ])('rejects an unsafe opaque-key self link %j', (href, message) => { + expect(() => extractOracleFusionOpaqueKey(resource(href), ORIGIN, COLLECTION_ADDRESS)).toThrow( + message + ) + }) +}) + +describe('Oracle self-link representation compatibility', () => { + const href = `${ORIGIN}${COLLECTION}/abc` + const links = [{ rel: 'self', href }] + const detailAddress = { family: 'hcm', relativePath: 'workers/abc' } as const + + it('accepts legacy links alongside context metadata without a links property', () => { + const value = { links, '@context': { key: 'not-the-resource-key', headers: { ETag: 'etag' } } } + expect(extractOracleFusionOpaqueKey(value, ORIGIN, COLLECTION_ADDRESS)).toBe('abc') + expect(() => validateOracleFusionSelfLink(value, ORIGIN, detailAddress)).not.toThrow() + }) + + it.each([undefined, null, [], 'context', 1].map((context) => ({ context })))( + 'rejects malformed context %# despite valid legacy links', + ({ context }) => { + const value = { links, '@context': context } + expect(() => extractOracleFusionOpaqueKey(value, ORIGIN, COLLECTION_ADDRESS)).toThrow( + 'Oracle resource context must be an object' + ) + expect(() => validateOracleFusionSelfLink(value, ORIGIN, detailAddress)).toThrow( + 'Oracle resource context must be an object' + ) + } + ) + + it.each([{}, { '@context': {} }, { '@context': { key: 'abc' } }, { '@context': { links: [] } }])( + 'rejects missing self links without deriving a key from context %#', + (value) => { + expect(() => extractOracleFusionOpaqueKey(value, ORIGIN, COLLECTION_ADDRESS)).toThrow( + 'exactly one' + ) + expect(() => validateOracleFusionSelfLink(value, ORIGIN, detailAddress)).toThrow( + 'exactly one' + ) + } + ) + + describe.each(['legacy', 'context'] as const)('invalid %s links', (representation) => { + it.each([ + { links: undefined, error: 'exactly one' }, + { links: null, error: 'exactly one' }, + { links: {}, error: 'exactly one' }, + { links: [], error: 'exactly one' }, + { links: [...links, ...links], error: 'exactly one' }, + { links: [{ rel: 'self' }], error: 'malformed' }, + { links: [{ rel: 'self', href: 'not a URL' }], error: 'malformed' }, + { + links: [{ rel: 'self', href: `${ORIGIN}${COLLECTION}/parent/../abc` }], + error: 'malformed', + }, + ])( + 'does not fall back to the other valid representation %#', + ({ links: invalidLinks, error }) => { + const value = { + links: representation === 'legacy' ? invalidLinks : links, + '@context': { links: representation === 'context' ? invalidLinks : links }, + } + expect(() => extractOracleFusionOpaqueKey(value, ORIGIN, COLLECTION_ADDRESS)).toThrow(error) + expect(() => validateOracleFusionSelfLink(value, ORIGIN, detailAddress)).toThrow(error) + } + ) + }) + + it.each([ + `${ORIGIN}${COLLECTION}/other`, + `https://other.fa.us2.oraclecloud.com${COLLECTION}/abc`, + `${ORIGIN.toUpperCase()}${COLLECTION}/abc`, + `${ORIGIN}${COLLECTION}/%61bc`, + ])('rejects conflicting href strings without normalizing or reflecting them %#', (otherHref) => { + for (const [legacyHref, contextHref] of [ + [href, otherHref], + [otherHref, href], + ]) { + const value = { + links: [{ rel: 'self', href: legacyHref }], + '@context': { links: [{ rel: 'self', href: contextHref }] }, + } + expect(() => extractOracleFusionOpaqueKey(value, ORIGIN, COLLECTION_ADDRESS)).toThrow( + new Error('Oracle response self-link representations conflict') + ) + expect(() => validateOracleFusionSelfLink(value, ORIGIN, detailAddress)).toThrow( + new Error('Oracle response self-link representations conflict') + ) + } + }) +}) diff --git a/apps/sim/lib/internal/oracle-fusion/protocol.ts b/apps/sim/lib/internal/oracle-fusion/protocol.ts new file mode 100644 index 00000000000..5db904ded98 --- /dev/null +++ b/apps/sim/lib/internal/oracle-fusion/protocol.ts @@ -0,0 +1,238 @@ +import { normalizeOracleFusionApplicationOrigin } from '@/lib/credentials/client-credential-accounts/descriptors' +import { + buildOracleFusionResourcePath, + type OracleFusionResourceAddress, +} from '@/lib/internal/oracle-fusion/paths' + +const OPAQUE_KEY_MAX_LENGTH = 2048 +const UNSAFE_OPAQUE_KEY = /[\\/?#\u0000-\u001f\u007f]/ +const UNSAFE_SELF_LINK_TEXT = /[\s\u0000-\u001f\u007f]/ +const RAW_SELF_LINK_DOT_SEGMENT = /(?:^|[\\/])(?:\.|%2e){1,2}(?=[\\/?#]|$)/i + +function hasWellFormedUtf16(value: string): boolean { + for (let index = 0; index < value.length; index++) { + const codeUnit = value.charCodeAt(index) + if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { + const next = value.charCodeAt(index + 1) + if (!(next >= 0xdc00 && next <= 0xdfff)) return false + index++ + } else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) { + return false + } + } + return true +} + +export interface OracleFusionCollection { + items: T[] + count: number + hasMore: boolean + limit: number + offset: number + totalResults?: number + nextOffset: number +} + +export interface OracleFusionCollectionOptions { + expectedOffset?: number + maxItems?: number +} + +function asObject(value: unknown, label: string): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`${label} must be an object`) + } + return value as Record +} + +function nonNegativeInteger(value: unknown, label: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { + throw new Error(`${label} must be a non-negative safe integer`) + } + return value +} + +/** Validates and projects an Oracle collection envelope with pagination invariants. */ +export function parseOracleFusionCollection( + value: unknown, + parseItem: (item: unknown, index: number) => T, + options: OracleFusionCollectionOptions = {} +): OracleFusionCollection { + const envelope = asObject(value, 'Oracle collection') + const count = nonNegativeInteger(envelope.count, 'Oracle collection count') + const limit = nonNegativeInteger(envelope.limit, 'Oracle collection limit') + const offset = nonNegativeInteger(envelope.offset, 'Oracle collection offset') + if (limit === 0) throw new Error('Oracle collection limit must be positive') + if (typeof envelope.hasMore !== 'boolean') { + throw new Error('Oracle collection hasMore must be a boolean') + } + const items = + envelope.items === undefined && count === 0 && !envelope.hasMore ? [] : envelope.items + if (!Array.isArray(items)) throw new Error('Oracle collection items must be an array') + if (count !== items.length) { + throw new Error('Oracle collection count must match the item count') + } + if (envelope.hasMore && count === 0) { + throw new Error('Oracle collection cannot report hasMore for an empty page') + } + + /** Estimated metadata only; it does not constrain the returned page or hasMore. */ + const totalResults = + envelope.totalResults === undefined + ? undefined + : nonNegativeInteger(envelope.totalResults, 'Oracle collection totalResults') + const pageEnd = offset + count + if (!Number.isSafeInteger(pageEnd)) { + throw new Error('Oracle collection next offset exceeds the safe integer range') + } + if (options.expectedOffset !== undefined) { + const expectedOffset = nonNegativeInteger( + options.expectedOffset, + 'Oracle collection expected offset' + ) + if (offset !== expectedOffset) { + throw new Error('Oracle collection offset does not match the requested offset') + } + } + if (options.maxItems !== undefined) { + const maxItems = nonNegativeInteger(options.maxItems, 'Oracle collection item limit') + if (items.length > maxItems) { + throw new Error('Oracle collection exceeds the requested item limit') + } + } + + return { + items: items.map(parseItem), + count, + hasMore: envelope.hasMore, + limit, + offset, + ...(totalResults !== undefined ? { totalResults } : {}), + nextOffset: pageEnd, + } +} + +function readSelfLink(links: unknown): { href: string; url: URL } { + if (!Array.isArray(links)) { + throw new Error('Oracle response must include exactly one self link') + } + const selfLinks = links.filter((link) => { + if (!link || typeof link !== 'object' || Array.isArray(link)) return false + return (link as Record).rel === 'self' + }) + if (selfLinks.length !== 1) { + throw new Error('Oracle response must include exactly one self link') + } + const href = (selfLinks[0] as Record).href + if ( + typeof href !== 'string' || + UNSAFE_SELF_LINK_TEXT.test(href) || + href.includes('\\') || + RAW_SELF_LINK_DOT_SEGMENT.test(href) || + !hasWellFormedUtf16(href) + ) { + throw new Error('Oracle self link is malformed') + } + try { + return { href, url: new URL(href) } + } catch { + throw new Error('Oracle self link is malformed') + } +} + +function getOnlySelfLink(value: unknown): URL { + const resource = asObject(value, 'Oracle resource') + const context = Object.hasOwn(resource, '@context') + ? asObject(resource['@context'], 'Oracle resource context') + : undefined + const legacyLink = Object.hasOwn(resource, 'links') ? readSelfLink(resource.links) : undefined + const contextLink = + context && Object.hasOwn(context, 'links') ? readSelfLink(context.links) : undefined + if (legacyLink && contextLink && legacyLink.href !== contextLink.href) { + throw new Error('Oracle response self-link representations conflict') + } + const link = contextLink ?? legacyLink + if (!link) throw new Error('Oracle response must include exactly one self link') + return link.url +} + +function validateSelfLinkBase(link: URL, instanceUrl: string): void { + const origin = normalizeOracleFusionApplicationOrigin(instanceUrl) + if ( + !origin || + link.origin !== origin || + link.username || + link.password || + link.search || + link.hash + ) { + throw new Error('Oracle self link does not match the credential-bound origin') + } +} + +/** Requires one canonical same-origin self link for the expected resource path. */ +export function validateOracleFusionSelfLink( + value: unknown, + instanceUrl: string, + address: OracleFusionResourceAddress +): void { + const link = getOnlySelfLink(value) + validateSelfLinkBase(link, instanceUrl) + if (link.pathname !== buildOracleFusionResourcePath(address)) { + throw new Error('Oracle response self link does not match the requested resource path') + } +} + +function validateOpaqueKey(key: string): string { + if ( + !key || + !key.trim() || + key.length > OPAQUE_KEY_MAX_LENGTH || + key === '.' || + key === '..' || + UNSAFE_OPAQUE_KEY.test(key) + ) { + throw new Error('Oracle resource key is not a safe opaque path segment') + } + if (!hasWellFormedUtf16(key)) { + throw new Error('Oracle resource key contains malformed Unicode') + } + return key +} + +/** Derives one opaque key from a canonical same-origin collection self link. */ +export function extractOracleFusionOpaqueKey( + value: unknown, + instanceUrl: string, + collectionAddress: OracleFusionResourceAddress +): string { + const link = getOnlySelfLink(value) + validateSelfLinkBase(link, instanceUrl) + const collectionPath = buildOracleFusionResourcePath(collectionAddress) + const prefix = `${collectionPath}/` + if (!link.pathname.startsWith(prefix)) { + throw new Error('Oracle self link does not match the requested collection path') + } + const encodedKey = link.pathname.slice(prefix.length) + if (!encodedKey || encodedKey.includes('/') || /%(?:2f|5c)/i.test(encodedKey)) { + throw new Error('Oracle self link does not contain one opaque key segment') + } + try { + return validateOpaqueKey(decodeURIComponent(encodedKey)) + } catch (error) { + if (error instanceof URIError) throw new Error('Oracle self link contains invalid URL encoding') + throw error + } +} + +/** Encodes a validated opaque Oracle resource key for one URL path segment. */ +export function encodeOracleFusionPathSegment(key: string): string { + try { + return encodeURIComponent(validateOpaqueKey(key)) + } catch (error) { + if (error instanceof URIError) { + throw new Error('Oracle resource key contains malformed Unicode') + } + throw error + } +} diff --git a/apps/sim/lib/internal/oracle-fusion/request-body.test.ts b/apps/sim/lib/internal/oracle-fusion/request-body.test.ts new file mode 100644 index 00000000000..9a738ffb79f --- /dev/null +++ b/apps/sim/lib/internal/oracle-fusion/request-body.test.ts @@ -0,0 +1,262 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { MAX_INLINE_MATERIALIZATION_BYTES } from '@/lib/execution/payloads/limits' +import { + type OracleFusionExactInteger, + oracleFusionExactInteger, + serializeOracleFusionJsonBody, +} from '@/lib/internal/oracle-fusion/request-body' + +describe('oracleFusionExactInteger', () => { + it.each([ + '0', + '42', + '-42', + '9007199254740991', + '999999999999999999', + '-999999999999999999', + '9'.repeat(128), + `-${'9'.repeat(128)}`, + ])('serializes the exact integer %s without quotes or rounding', (digits) => { + const integer = oracleFusionExactInteger(digits) + expect(serializeOracleFusionJsonBody(integer)).toBe(digits) + expect(serializeOracleFusionJsonBody({ id: integer, text: digits, array: [integer] })).toBe( + `{"id":${digits},"text":"${digits}","array":[${digits}]}` + ) + }) + + it.each([ + '', + ' ', + ' 1', + '1 ', + '1\n', + '1\r', + '1\r\n', + '1\t', + '1\u2028', + '1\u2029', + '1\u0000', + '1\u007f', + '1\uD800', + '1😀', + '12', + '+1', + '-0', + '00', + '01', + '-01', + '-', + '1.0', + '1.5', + '1e3', + '1E+3', + '0x10', + 'NaN', + 'Infinity', + '1,"injected":true', + '9'.repeat(129), + `-${'9'.repeat(129)}`, + ])('rejects noncanonical or excessive integer text %# with a fixed error', (digits) => { + expect(() => oracleFusionExactInteger(digits)).toThrow( + new Error( + 'Oracle Fusion exact integer must be a canonical decimal string of at most 128 digits' + ) + ) + }) + + it.each( + [ + undefined, + null, + 42, + Number.MAX_SAFE_INTEGER + 1, + 42n, + false, + {}, + ['42'], + { toString: () => '42' }, + ].map((value) => ({ value })) + )('rejects non-string values without coercion %#', ({ value }) => { + expect(() => oracleFusionExactInteger(value as unknown as string)).toThrow( + new Error( + 'Oracle Fusion exact integer must be a canonical decimal string of at most 128 digits' + ) + ) + }) + + it('keeps validated digits private in a frozen in-process wrapper', () => { + const integer = oracleFusionExactInteger('999999999999999999') + expect(Object.isFrozen(integer)).toBe(true) + expect(Reflect.ownKeys(integer)).toEqual([]) + expect(JSON.stringify(integer)).toBe('{}') + expect(Reflect.set(integer, 'value', '0')).toBe(false) + expect(serializeOracleFusionJsonBody(integer)).toBe('999999999999999999') + }) + + it('does not treat copied wrappers or lookalike objects as raw numbers', () => { + const integer = oracleFusionExactInteger('999999999999999999') + const lookalike = Object.freeze({ + rawJSON: '999999999999999999', + }) as unknown as OracleFusionExactInteger + expect(serializeOracleFusionJsonBody(lookalike)).toBe('{"rawJSON":"999999999999999999"}') + expect(serializeOracleFusionJsonBody({ ...integer })).toBe('{}') + expect(serializeOracleFusionJsonBody(structuredClone(integer))).toBe('{}') + expect(() => serializeOracleFusionJsonBody(Object.create(integer))).toThrow('plain JSON data') + expect(() => serializeOracleFusionJsonBody({ toJSON: () => integer })).toThrow( + 'plain JSON data' + ) + expect(() => + serializeOracleFusionJsonBody( + Object.defineProperty({}, 'id', { + enumerable: true, + get: () => integer, + }) + ) + ).toThrow('plain JSON data') + }) + + it('counts wrappers against nesting and node budgets as scalar values', () => { + const integer = oracleFusionExactInteger('0') + let nested: unknown = integer + for (let index = 0; index < 100; index += 1) nested = [nested] + expect(serializeOracleFusionJsonBody(nested)).toBe(`${'['.repeat(100)}0${']'.repeat(100)}`) + expect(() => serializeOracleFusionJsonBody([nested])).toThrow('nesting limit') + + expect(serializeOracleFusionJsonBody(new Array(99_999).fill(integer)).length).toBe(199_999) + expect(() => + serializeOracleFusionJsonBody({ values: new Array(99_998).fill(integer), extra: integer }) + ).toThrow('complexity limit') + }) + + it('charges the full unquoted integer text to the existing byte budget', () => { + const digits = '9'.repeat(128) + const integer = oracleFusionExactInteger(digits) + const padding = 'x'.repeat(MAX_INLINE_MATERIALIZATION_BYTES - digits.length - 5) + const serialized = serializeOracleFusionJsonBody([padding, integer]) + expect(Buffer.byteLength(serialized, 'utf8')).toBe(MAX_INLINE_MATERIALIZATION_BYTES) + expect(serialized.endsWith(`",${digits}]`)).toBe(true) + expect(() => serializeOracleFusionJsonBody([`${padding}x`, integer])).toThrow( + 'inline payload limit' + ) + }) +}) + +describe('serializeOracleFusionJsonBody', () => { + it('serializes plain objects, arrays, null-prototype objects, and JSON scalars', () => { + const nullPrototype = Object.create(null) as Record + nullPrototype.value = 'ok' + + expect( + serializeOracleFusionJsonBody({ + string: 'value', + number: 12.5, + boolean: false, + nil: null, + array: [1, 'two'], + nullPrototype, + }) + ).toBe( + '{"string":"value","number":12.5,"boolean":false,"nil":null,"array":[1,"two"],"nullPrototype":{"value":"ok"}}' + ) + }) + + it.each([ + undefined, + () => undefined, + Symbol('value'), + 1n, + Number.NaN, + Number.POSITIVE_INFINITY, + new Date(), + ])('rejects unsupported root values %#', (value) => { + expect(() => serializeOracleFusionJsonBody(value)).toThrow('plain JSON data') + }) + + it('rejects unsupported nested values instead of applying JSON omission rules', () => { + expect(() => serializeOracleFusionJsonBody({ missing: undefined })).toThrow('plain JSON data') + expect(() => serializeOracleFusionJsonBody([undefined])).toThrow('plain JSON data') + expect(() => serializeOracleFusionJsonBody(new Array(1))).toThrow('plain JSON data') + }) + + it('rejects custom serialization, accessors, symbols, and array properties', () => { + expect(() => serializeOracleFusionJsonBody({ toJSON: () => ({}) })).toThrow('plain JSON data') + + const accessor = Object.defineProperty({}, 'value', { + enumerable: true, + get: () => 'secret', + }) + expect(() => serializeOracleFusionJsonBody(accessor)).toThrow('plain JSON data') + + expect(() => serializeOracleFusionJsonBody({ [Symbol('secret')]: 'value' })).toThrow( + 'plain JSON data' + ) + + const array = [1] + Object.defineProperty(array, 'extra', { value: true, enumerable: true }) + expect(() => serializeOracleFusionJsonBody(array)).toThrow('plain JSON data') + + const customArray = [1] + Object.setPrototypeOf(customArray, null) + expect(() => serializeOracleFusionJsonBody(customArray)).toThrow('plain JSON data') + }) + + it('rejects inherited custom serialization before JSON.stringify can invoke it', () => { + const previous = Object.getOwnPropertyDescriptor(Array.prototype, 'toJSON') + Object.defineProperty(Array.prototype, 'toJSON', { + configurable: true, + value: () => ({ replaced: true }), + }) + try { + expect(() => serializeOracleFusionJsonBody([1])).toThrow('plain JSON data') + } finally { + if (previous) Object.defineProperty(Array.prototype, 'toJSON', previous) + else Reflect.deleteProperty(Array.prototype, 'toJSON') + } + }) + + it('serializes the descriptor values captured from a proxy exactly once', () => { + const target = { value: 'first' } + let descriptorReads = 0 + const proxy = new Proxy(target, { + getOwnPropertyDescriptor(current, key) { + descriptorReads += 1 + const descriptor = Reflect.getOwnPropertyDescriptor(current, key) + return descriptor ? { ...descriptor, value: `read-${descriptorReads}` } : undefined + }, + }) + + expect(serializeOracleFusionJsonBody(proxy)).toBe('{"value":"read-1"}') + expect(descriptorReads).toBe(1) + + const arrayProxy = new Proxy([1], { + get(_current, key) { + if (key === 'length') throw new Error('array length getter must not run') + return undefined + }, + }) + expect(serializeOracleFusionJsonBody(arrayProxy)).toBe('[1]') + }) + + it('rejects cycles, excessive nesting, and excessive complexity', () => { + const cycle: unknown[] = [] + cycle.push(cycle) + expect(() => serializeOracleFusionJsonBody(cycle)).toThrow('must not be cyclic') + + let nested: unknown = null + for (let index = 0; index < 101; index += 1) nested = [nested] + expect(() => serializeOracleFusionJsonBody(nested)).toThrow('nesting limit') + + expect(() => serializeOracleFusionJsonBody(new Array(100_001).fill(null))).toThrow( + 'complexity limit' + ) + }) + + it('rejects UTF-8 output beyond the inline materialization limit', () => { + expect(() => + serializeOracleFusionJsonBody('x'.repeat(MAX_INLINE_MATERIALIZATION_BYTES)) + ).toThrow('inline payload limit') + }) +}) diff --git a/apps/sim/lib/internal/oracle-fusion/request-body.ts b/apps/sim/lib/internal/oracle-fusion/request-body.ts new file mode 100644 index 00000000000..135823bb04a --- /dev/null +++ b/apps/sim/lib/internal/oracle-fusion/request-body.ts @@ -0,0 +1,302 @@ +import { MAX_INLINE_MATERIALIZATION_BYTES } from '@/lib/execution/payloads/limits' + +const MAX_JSON_NESTING_DEPTH = 100 +const MAX_JSON_NODE_COUNT = 100_000 +const MAX_EXACT_INTEGER_DIGITS = 128 +/** The final assertion requires the entire string, including any trailing line breaks. */ +const CANONICAL_EXACT_INTEGER = /^(?:0|-?[1-9][0-9]*)(?![\s\S])/ +const exactIntegerValues = new WeakMap() + +declare const oracleFusionExactIntegerBrand: unique symbol + +/** An in-process exact integer recognized only by the Fusion request-body serializer. */ +export interface OracleFusionExactInteger { + readonly [oracleFusionExactIntegerBrand]: true +} + +class OracleFusionRequestBodyError extends Error {} + +/** + * Preserves a canonical decimal integer as an unquoted JSON number, capped at 128 magnitude digits. + * Construct in server-side request builders after product-specific range and sign validation. + * Keep persisted workflow inputs as strings; create this wrapper after any other JSON serialization. + */ +export function oracleFusionExactInteger(value: string): OracleFusionExactInteger { + if ( + typeof value !== 'string' || + value.length > MAX_EXACT_INTEGER_DIGITS + (value.startsWith('-') ? 1 : 0) || + !CANONICAL_EXACT_INTEGER.test(value) + ) { + throw new OracleFusionRequestBodyError( + 'Oracle Fusion exact integer must be a canonical decimal string of at most 128 digits' + ) + } + const integer = Object.freeze({}) as OracleFusionExactInteger + exactIntegerValues.set(integer, value) + return integer +} + +interface JsonBudgetState { + bytes: number + nodes: number + ancestors: WeakSet + fragments: string[] +} + +type JsonBudgetFrame = + | { kind: 'value'; value: unknown; depth: number } + | { + kind: 'array' + owner: unknown[] + values: unknown[] + index: number + depth: number + } + | { + kind: 'object' + owner: Record + entries: [string, unknown][] + index: number + depth: number + } + +/** Serializes a bounded request body containing plain JSON data and validated exact integers. */ +export function serializeOracleFusionJsonBody(body: unknown): string { + try { + const state = serializeJsonBodyWithinLimit(body) + const serialized = state.fragments.join('') + if (Buffer.byteLength(serialized, 'utf8') > MAX_INLINE_MATERIALIZATION_BYTES) { + throwRequestBodyLimitError() + } + return serialized + } catch (error) { + if (error instanceof OracleFusionRequestBodyError) throw error + throwNonPlainJsonError() + } +} + +function serializeJsonBodyWithinLimit(body: unknown): JsonBudgetState { + const state: JsonBudgetState = { + bytes: 0, + nodes: 0, + ancestors: new WeakSet(), + fragments: [], + } + const frames: JsonBudgetFrame[] = [{ kind: 'value', value: body, depth: 0 }] + + while (frames.length > 0) { + const frame = frames.pop() + if (!frame) break + + if (frame.kind === 'array') { + if (frame.index >= frame.values.length) { + appendJsonFragment(state, ']') + state.ancestors.delete(frame.owner) + continue + } + if (frame.index > 0) appendJsonFragment(state, ',') + frames.push({ ...frame, index: frame.index + 1 }) + frames.push({ + kind: 'value', + value: frame.values[frame.index], + depth: frame.depth + 1, + }) + continue + } + + if (frame.kind === 'object') { + if (frame.index >= frame.entries.length) { + appendJsonFragment(state, '}') + state.ancestors.delete(frame.owner) + continue + } + const [key, value] = frame.entries[frame.index] + if (frame.index > 0) appendJsonFragment(state, ',') + appendJsonString(state, key) + appendJsonFragment(state, ':') + frames.push({ ...frame, index: frame.index + 1 }) + frames.push({ kind: 'value', value, depth: frame.depth + 1 }) + continue + } + + admitJsonNode(state) + const { value, depth } = frame + if (depth > MAX_JSON_NESTING_DEPTH) { + throw new OracleFusionRequestBodyError( + 'Oracle Fusion request body exceeds the JSON nesting limit' + ) + } + const exactInteger = + value !== null && typeof value === 'object' ? exactIntegerValues.get(value) : undefined + if (exactInteger !== undefined) { + appendJsonFragment(state, exactInteger) + } else if (value === null) { + appendJsonFragment(state, 'null') + } else if (typeof value === 'string') { + appendJsonString(state, value) + } else if (typeof value === 'boolean') { + appendJsonFragment(state, value ? 'true' : 'false') + } else if (typeof value === 'number') { + if (!Number.isFinite(value)) throwNonPlainJsonError() + appendJsonFragment(state, JSON.stringify(value)) + } else if (Array.isArray(value)) { + const prototype = Object.getPrototypeOf(value) + if (prototype !== Array.prototype) throwNonPlainJsonError() + if (state.ancestors.has(value)) { + throw new OracleFusionRequestBodyError('Oracle Fusion request body must not be cyclic') + } + const values = captureArrayValues(value, prototype) + if (values.length * 2 + 1 > MAX_INLINE_MATERIALIZATION_BYTES - state.bytes) { + throwRequestBodyLimitError() + } + state.ancestors.add(value) + appendJsonFragment(state, '[') + frames.push({ kind: 'array', owner: value, values, index: 0, depth }) + } else if (isRecordLike(value)) { + const prototype = Object.getPrototypeOf(value) + if (prototype !== Object.prototype && prototype !== null) throwNonPlainJsonError() + if (state.ancestors.has(value)) { + throw new OracleFusionRequestBodyError('Oracle Fusion request body must not be cyclic') + } + const entries = captureObjectEntries(value, prototype) + state.ancestors.add(value) + appendJsonFragment(state, '{') + frames.push({ kind: 'object', owner: value, entries, index: 0, depth }) + } else { + throwNonPlainJsonError() + } + } + + return state +} + +function isRecordLike(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function rejectInheritedJsonSerialization(prototype: object | null): void { + for (let candidate = prototype; candidate; candidate = Object.getPrototypeOf(candidate)) { + if (Object.hasOwn(candidate, 'toJSON')) throwNonPlainJsonError() + } +} + +function captureArrayValues(value: unknown[], prototype: object): unknown[] { + rejectInheritedJsonSerialization(prototype) + const ownKeys = Reflect.ownKeys(value) + if (ownKeys.length > MAX_JSON_NODE_COUNT + 1) throwComplexityLimitError() + const lengthDescriptor = Object.getOwnPropertyDescriptor(value, 'length') + const length = lengthDescriptor?.value + if ( + typeof length !== 'number' || + !Number.isSafeInteger(length) || + length < 0 || + length >= MAX_JSON_NODE_COUNT + ) { + throwComplexityLimitError() + } + const values = new Array(length) + let captured = 0 + + for (const key of ownKeys) { + if (typeof key === 'symbol') throwNonPlainJsonError() + if (key === 'length') continue + const index = Number(key) + if (!Number.isSafeInteger(index) || index < 0 || String(index) !== key || index >= length) { + throwNonPlainJsonError() + } + const descriptor = Object.getOwnPropertyDescriptor(value, key) + if (!descriptor || descriptor.get || descriptor.set) throwNonPlainJsonError() + values[index] = descriptor.value + captured += 1 + } + + if (captured !== length) throwNonPlainJsonError() + return values +} + +function captureObjectEntries( + value: Record, + prototype: object | null +): [string, unknown][] { + rejectInheritedJsonSerialization(prototype) + const ownKeys = Reflect.ownKeys(value) + if (ownKeys.length > MAX_JSON_NODE_COUNT) throwComplexityLimitError() + const entries: [string, unknown][] = [] + + for (const key of ownKeys) { + if (typeof key === 'symbol' || key === 'toJSON') throwNonPlainJsonError() + const descriptor = Object.getOwnPropertyDescriptor(value, key) + if (!descriptor || descriptor.get || descriptor.set) throwNonPlainJsonError() + if (descriptor.enumerable) entries.push([key, descriptor.value]) + } + + return entries +} + +function admitJsonNode(state: JsonBudgetState): void { + state.nodes += 1 + if (state.nodes > MAX_JSON_NODE_COUNT) throwComplexityLimitError() +} + +function appendJsonFragment(state: JsonBudgetState, fragment: string): void { + reserveJsonBytes(state, Buffer.byteLength(fragment, 'utf8')) + state.fragments.push(fragment) +} + +function appendJsonString(state: JsonBudgetState, value: string): void { + reserveJsonBytes(state, jsonStringByteLength(value)) + state.fragments.push(JSON.stringify(value)) +} + +function reserveJsonBytes(state: JsonBudgetState, bytes: number): void { + state.bytes += bytes + if (state.bytes > MAX_INLINE_MATERIALIZATION_BYTES) throwRequestBodyLimitError() +} + +function jsonStringByteLength(value: string): number { + let bytes = 2 + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index) + if (code === 0x22 || code === 0x5c) { + bytes += 2 + } else if (code < 0x20) { + bytes += + code === 0x08 || code === 0x09 || code === 0x0a || code === 0x0c || code === 0x0d ? 2 : 6 + } else if (code < 0x80) { + bytes += 1 + } else if (code < 0x800) { + bytes += 2 + } else if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(index + 1) + if (next >= 0xdc00 && next <= 0xdfff) { + bytes += 4 + index += 1 + } else { + bytes += 6 + } + } else if (code >= 0xdc00 && code <= 0xdfff) { + bytes += 6 + } else { + bytes += 3 + } + } + return bytes +} + +function throwRequestBodyLimitError(): never { + throw new OracleFusionRequestBodyError( + 'Oracle Fusion request body exceeds the inline payload limit' + ) +} + +function throwComplexityLimitError(): never { + throw new OracleFusionRequestBodyError( + 'Oracle Fusion request body exceeds the JSON complexity limit' + ) +} + +function throwNonPlainJsonError(): never { + throw new OracleFusionRequestBodyError( + 'Oracle Fusion request body must contain plain JSON data without accessors or custom serialization' + ) +}