Skip to content

Commit 98d5e46

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
feat(oci): add native Object Storage integration
1 parent 3fa59e7 commit 98d5e46

62 files changed

Lines changed: 5955 additions & 7 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/docs/components/ui/icon-mapping.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -482,6 +482,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
482482
notion: NotionIcon,
483483
notion_v2: NotionIcon,
484484
obsidian: ObsidianIcon,
485+
oci_object_storage_native: NetSuiteIcon,
485486
okta: OktaIcon,
486487
onedrive: MicrosoftOneDriveIcon,
487488
onepassword: OnePasswordIcon,

apps/docs/content/docs/integrations/meta.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,7 @@
185185
"notion",
186186
"notion-service-account",
187187
"obsidian",
188+
"oci_object_storage_native",
188189
"okta",
189190
"onedrive",
190191
"onepassword",

apps/docs/content/docs/integrations/oci_object_storage_native.mdx

Lines changed: 1149 additions & 0 deletions
Large diffs are not rendered by default.

apps/sim/blocks/blocks/oci_object_storage_native.ts

Lines changed: 844 additions & 0 deletions
Large diffs are not rendered by default.

apps/sim/blocks/registry-maps.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,7 @@ import { MSSQLBlock, MSSQLBlockMeta } from '@/blocks/blocks/mssql'
239239
import { MySQLBlock, MySQLBlockMeta } from '@/blocks/blocks/mysql'
240240
import { Neo4jBlock, Neo4jBlockMeta } from '@/blocks/blocks/neo4j'
241241
import { NetSuiteBlock, NetSuiteBlockMeta } from '@/blocks/blocks/netsuite'
242+
import { OciObjectStorageNativeBlock, OciObjectStorageNativeBlockMeta } from '@/blocks/blocks/oci_object_storage_native'
242243
import { NeverBounceBlock, NeverBounceBlockMeta } from '@/blocks/blocks/neverbounce'
243244
import { NewRelicBlock, NewRelicBlockMeta } from '@/blocks/blocks/new_relic'
244245
import { NoteBlock } from '@/blocks/blocks/note'
@@ -590,6 +591,7 @@ export const BLOCK_REGISTRY: Record<string, BlockConfig> = {
590591
mysql: MySQLBlock,
591592
neo4j: Neo4jBlock,
592593
netsuite: NetSuiteBlock,
594+
oci_object_storage_native: OciObjectStorageNativeBlock,
593595
new_relic: NewRelicBlock,
594596
note: NoteBlock,
595597
notion: NotionBlock,
@@ -915,6 +917,7 @@ export const BLOCK_META_REGISTRY: Record<string, BlockMeta> = {
915917
mysql: MySQLBlockMeta,
916918
neo4j: Neo4jBlockMeta,
917919
netsuite: NetSuiteBlockMeta,
920+
oci_object_storage_native: OciObjectStorageNativeBlockMeta,
918921
neverbounce: NeverBounceBlockMeta,
919922
new_relic: NewRelicBlockMeta,
920923
notion: NotionBlockMeta,

apps/sim/lib/copilot/generated/docs-manifest.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,7 @@ export const DOCS_MANIFEST: readonly string[] = [
243243
'integrations/notion-service-account.mdx',
244244
'integrations/notion.mdx',
245245
'integrations/obsidian.mdx',
246+
'integrations/oci_object_storage_native.mdx',
246247
'integrations/okta.mdx',
247248
'integrations/onedrive.mdx',
248249
'integrations/onepassword.mdx',

apps/sim/lib/integrations/icon-mapping.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
465465
notion: NotionIcon,
466466
notion_v2: NotionIcon,
467467
obsidian: ObsidianIcon,
468+
oci_object_storage_native: NetSuiteIcon,
468469
okta: OktaIcon,
469470
onedrive: MicrosoftOneDriveIcon,
470471
onepassword: OnePasswordIcon,
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
2+
import { OciClientError } from '@/lib/internal/oci/errors'
3+
4+
export class OciNativeOperationError extends Error {
5+
constructor(message: string, readonly status = 400) {
6+
super(message)
7+
this.name = 'OciNativeOperationError'
8+
}
9+
}
10+
11+
/** Keep credential material, provider response bodies, and storage errors out of tool failures. */
12+
export function normalizeOciNativeError(error: unknown): { status: number; message: string } {
13+
if (error instanceof OciNativeOperationError) return { status: error.status, message: error.message }
14+
if (isPayloadSizeLimitError(error)) return { status: 413, message: 'File exceeds the 100 MiB transfer limit' }
15+
if (error instanceof OciClientError) {
16+
return {
17+
status: error.status ?? (error.code === 'response_too_large' ? 413 : error.code === 'deadline_exceeded' ? 504 : 502),
18+
message: error.message,
19+
}
20+
}
21+
return { status: 500, message: 'OCI Object Storage operation failed' }
22+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
/** @vitest-environment node */
2+
import { beforeEach, describe, expect, it, vi } from 'vitest'
3+
4+
const mocks = vi.hoisted(() => ({ executeOciNativeOperation: vi.fn() }))
5+
vi.mock('@/lib/internal/oci-object-storage-native/operations', () => mocks)
6+
7+
import { OciClientError } from '@/lib/internal/oci/errors'
8+
import { executeOciObjectStorageNativeTool } from '@/lib/internal/oci-object-storage-native/execute-tool'
9+
import type { InternalToolOperationCall } from '@/lib/internal/tool-operations/types'
10+
import { createOciNativeOperationInput } from '@/tools/oci_object_storage_native/shared'
11+
12+
const AUTH = { credentialId: 'authorized', namespace: 'namespace' }
13+
const BUCKET = { ...AUTH, bucketName: 'reports' }
14+
const OBJECT = { ...BUCKET, objectName: 'report.txt' }
15+
const MULTIPART = { ...OBJECT, uploadId: 'upload' }
16+
const CASES: [string, Record<string, unknown>][] = [
17+
['get_namespace', AUTH], ['list_buckets', { ...AUTH, compartmentId: 'compartment' }],
18+
['get_bucket', BUCKET], ['create_bucket', { ...BUCKET, compartmentId: 'compartment' }],
19+
['update_bucket', { ...BUCKET, versioning: 'Enabled' }], ['delete_bucket', BUCKET],
20+
['list_objects', BUCKET], ['head_object', OBJECT], ['upload_object', { ...OBJECT, content: '' }],
21+
['download_object', OBJECT], ['copy_object', { ...OBJECT, destinationRegion: 'us-phoenix-1', destinationNamespace: 'namespace', destinationBucket: 'copies', destinationObjectName: 'copy' }],
22+
['rename_object', { ...OBJECT, newName: 'new' }], ['delete_object', OBJECT],
23+
['batch_delete_objects', { ...BUCKET, objects: [{ objectName: 'report.txt' }] }],
24+
['list_object_versions', BUCKET], ['restore_object', OBJECT],
25+
['update_object_storage_tier', { ...OBJECT, storageTier: 'Archive' }],
26+
['get_lifecycle_policy', BUCKET], ['put_lifecycle_policy', { ...BUCKET, rules: [] }], ['delete_lifecycle_policy', BUCKET],
27+
['create_multipart_upload', OBJECT], ['upload_part', { ...MULTIPART, partNumber: 1, content: '' }],
28+
['list_multipart_uploads', BUCKET], ['list_multipart_parts', MULTIPART],
29+
['commit_multipart_upload', { ...MULTIPART, partsToCommit: [{ partNum: 1, etag: 'etag' }] }],
30+
['abort_multipart_upload', MULTIPART],
31+
['create_preauthenticated_request', { ...OBJECT, name: 'Report', scope: 'object', accessType: 'ObjectRead', timeExpires: '2099-01-01T00:00:00Z' }],
32+
['list_preauthenticated_requests', BUCKET], ['get_preauthenticated_request', { ...BUCKET, parId: 'par' }],
33+
['delete_preauthenticated_request', { ...BUCKET, parId: 'par' }], ['get_work_request', { ...AUTH, workRequestId: 'work' }],
34+
]
35+
36+
function request(operation: string, input: unknown): InternalToolOperationCall {
37+
return { toolId: `oci_object_storage_native_${operation}`, input, headers: new Headers(),
38+
context: { workflowId: 'workflow', workspaceId: 'trusted-workspace', userId: 'actor' }, requestId: 'request' }
39+
}
40+
41+
describe('native OCI tool operation handler', () => {
42+
beforeEach(() => {
43+
vi.clearAllMocks()
44+
mocks.executeOciNativeOperation.mockResolvedValue({ success: true, output: {} })
45+
})
46+
47+
it.each(CASES)('validates and dispatches %s with trusted workspace context', async (operation, input) => {
48+
const result = await executeOciObjectStorageNativeTool(request(operation, input))
49+
expect(result.status).toBe(200)
50+
expect(mocks.executeOciNativeOperation).toHaveBeenCalledWith(expect.objectContaining({ ...input, operation }), {
51+
workspaceId: 'trusted-workspace', workflowId: 'workflow', executionId: undefined, userId: 'actor', requestId: 'request', signal: undefined,
52+
})
53+
})
54+
55+
it('maps the authorized hidden reference and strips the caller execution context', async () => {
56+
const input = createOciNativeOperationInput({ oauthCredential: 'visible-selection', accessToken: 'authorized', _context: { workspaceId: 'attacker' }, _credentialId: 'bookkeeping', _workflowId: 'workflow', credential: undefined, impersonateUserEmail: undefined, namespace: 'namespace' })
57+
expect(input).toEqual(AUTH)
58+
await executeOciObjectStorageNativeTool(request('get_namespace', input))
59+
expect(mocks.executeOciNativeOperation).toHaveBeenCalledWith(expect.objectContaining({ credentialId: 'authorized' }), expect.objectContaining({ workspaceId: 'trusted-workspace' }))
60+
const missing = createOciNativeOperationInput({ oauthCredential: 'visible-selection' })
61+
expect((await executeOciObjectStorageNativeTool(request('get_namespace', missing))).status).toBe(400)
62+
})
63+
64+
it.each([{ ...AUTH, workspaceId: 'injected' }, { ...AUTH, operation: 'delete_bucket' }, { ...AUTH, authorization: 'injected' }])('rejects unexpected authority or operation fields', async (input) => {
65+
expect((await executeOciObjectStorageNativeTool(request('get_namespace', input))).status).toBe(400)
66+
expect(mocks.executeOciNativeOperation).not.toHaveBeenCalled()
67+
})
68+
69+
it('requires trusted workspace scope', async () => {
70+
const call = request('get_namespace', AUTH)
71+
delete call.context.workspaceId
72+
expect((await executeOciObjectStorageNativeTool(call)).status).toBe(403)
73+
expect(mocks.executeOciNativeOperation).not.toHaveBeenCalled()
74+
})
75+
76+
it('uses the delegated subject for file authorization', async () => {
77+
const call = request('upload_object', { ...OBJECT, file: { key: 'file', name: 'file.txt', size: 0 } })
78+
call.context.executorDelegationOrigin = { subjectUserId: 'delegated-actor', workflowId: 'origin-workflow', executionId: 'origin-execution' }
79+
await executeOciObjectStorageNativeTool(call)
80+
expect(mocks.executeOciNativeOperation).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ userId: 'delegated-actor', workspaceId: 'trusted-workspace' }))
81+
})
82+
83+
it('projects safe foundation failures without exposing arbitrary error details', async () => {
84+
mocks.executeOciNativeOperation.mockRejectedValueOnce(new OciClientError('request_failed', { status: 412 }))
85+
const known = await executeOciObjectStorageNativeTool(request('get_namespace', AUTH))
86+
expect(known.status).toBe(412)
87+
await expect(known.json()).resolves.toEqual({ success: false, error: 'OCI request failed' })
88+
mocks.executeOciNativeOperation.mockRejectedValueOnce(new Error('private-key-or-storage-secret'))
89+
const unknown = await executeOciObjectStorageNativeTool(request('get_namespace', AUTH))
90+
await expect(unknown.json()).resolves.toEqual({ success: false, error: 'OCI Object Storage operation failed' })
91+
})
92+
93+
it('preserves cancellation before and after provider work', async () => {
94+
const controller = new AbortController()
95+
const reason = new DOMException('Canceled', 'AbortError')
96+
const call = { ...request('get_namespace', AUTH), signal: controller.signal }
97+
mocks.executeOciNativeOperation.mockImplementationOnce(async () => { controller.abort(reason); throw reason })
98+
await expect(executeOciObjectStorageNativeTool(call)).rejects.toBe(reason)
99+
mocks.executeOciNativeOperation.mockClear()
100+
await expect(executeOciObjectStorageNativeTool(call)).rejects.toBe(reason)
101+
expect(mocks.executeOciNativeOperation).not.toHaveBeenCalled()
102+
})
103+
})
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { createLogger } from '@sim/logger'
2+
import { isPlainRecord } from '@sim/utils/object'
3+
import { getValidationErrorMessage } from '@/lib/api/server'
4+
import { normalizeOciNativeError } from '@/lib/internal/oci-object-storage-native/errors'
5+
import { executeOciNativeOperation } from '@/lib/internal/oci-object-storage-native/operations'
6+
import { ociNativeInputSchema } from '@/lib/internal/oci-object-storage-native/schema'
7+
import type { InternalToolOperationHandler } from '@/lib/internal/tool-operations/types'
8+
9+
const logger = createLogger('OciObjectStorageNativeToolExecution')
10+
const PREFIX = 'oci_object_storage_native_'
11+
12+
export const executeOciObjectStorageNativeTool: InternalToolOperationHandler = async (request) => {
13+
request.signal?.throwIfAborted()
14+
if (!request.toolId.startsWith(PREFIX) || !isPlainRecord(request.input) || 'operation' in request.input) {
15+
return Response.json({ success: false, error: 'Invalid native OCI tool input' }, { status: 400 })
16+
}
17+
const parsed = ociNativeInputSchema.safeParse({ ...request.input, operation: request.toolId.slice(PREFIX.length) })
18+
if (!parsed.success) return Response.json({ success: false, error: getValidationErrorMessage(parsed.error, 'Invalid native OCI request') }, { status: 400 })
19+
if (!request.context.workspaceId) return Response.json({ success: false, error: 'Workspace context is required' }, { status: 403 })
20+
try {
21+
const result = await executeOciNativeOperation(parsed.data, {
22+
workspaceId: request.context.workspaceId,
23+
workflowId: request.context.workflowId,
24+
executionId: request.context.executionId,
25+
userId: request.context.executorDelegationOrigin?.subjectUserId ?? request.context.userId,
26+
requestId: request.requestId, signal: request.signal,
27+
})
28+
request.signal?.throwIfAborted()
29+
return Response.json(result)
30+
} catch (error) {
31+
request.signal?.throwIfAborted()
32+
const normalized = normalizeOciNativeError(error)
33+
logger.warn('Native OCI operation failed', { requestId: request.requestId, toolId: request.toolId, status: normalized.status })
34+
return Response.json({ success: false, error: normalized.message }, { status: normalized.status })
35+
}
36+
}

0 commit comments

Comments
 (0)