Skip to content

Commit a5898ee

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

62 files changed

Lines changed: 8732 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: 2569 additions & 0 deletions
Large diffs are not rendered by default.

apps/sim/blocks/registry-maps.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,10 @@ import {
249249
NotionV2BlockMeta,
250250
} from '@/blocks/blocks/notion'
251251
import { ObsidianBlock, ObsidianBlockMeta } from '@/blocks/blocks/obsidian'
252+
import {
253+
OciObjectStorageNativeBlock,
254+
OciObjectStorageNativeBlockMeta,
255+
} from '@/blocks/blocks/oci_object_storage_native'
252256
import { OktaBlock, OktaBlockMeta } from '@/blocks/blocks/okta'
253257
import { OneDriveBlock, OneDriveBlockMeta } from '@/blocks/blocks/onedrive'
254258
import { OnePasswordBlock, OnePasswordBlockMeta } from '@/blocks/blocks/onepassword'
@@ -590,6 +594,7 @@ export const BLOCK_REGISTRY: Record<string, BlockConfig> = {
590594
mysql: MySQLBlock,
591595
neo4j: Neo4jBlock,
592596
netsuite: NetSuiteBlock,
597+
oci_object_storage_native: OciObjectStorageNativeBlock,
593598
new_relic: NewRelicBlock,
594599
note: NoteBlock,
595600
notion: NotionBlock,
@@ -915,6 +920,7 @@ export const BLOCK_META_REGISTRY: Record<string, BlockMeta> = {
915920
mysql: MySQLBlockMeta,
916921
neo4j: Neo4jBlockMeta,
917922
netsuite: NetSuiteBlockMeta,
923+
oci_object_storage_native: OciObjectStorageNativeBlockMeta,
918924
neverbounce: NeverBounceBlockMeta,
919925
new_relic: NewRelicBlockMeta,
920926
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: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
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(
6+
message: string,
7+
readonly status = 400
8+
) {
9+
super(message)
10+
this.name = 'OciNativeOperationError'
11+
}
12+
}
13+
14+
/** Keep credential material, provider response bodies, and storage errors out of tool failures. */
15+
export function normalizeOciNativeError(error: unknown): { status: number; message: string } {
16+
if (error instanceof OciNativeOperationError)
17+
return { status: error.status, message: error.message }
18+
if (isPayloadSizeLimitError(error))
19+
return { status: 413, message: 'File exceeds the 100 MiB transfer limit' }
20+
if (error instanceof OciClientError) {
21+
return {
22+
status:
23+
error.status ??
24+
(error.code === 'response_too_large'
25+
? 413
26+
: error.code === 'deadline_exceeded'
27+
? 504
28+
: 502),
29+
message: error.message,
30+
}
31+
}
32+
return { status: 500, message: 'OCI Object Storage operation failed' }
33+
}
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
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],
18+
['list_buckets', { ...AUTH, compartmentId: 'compartment' }],
19+
['get_bucket', BUCKET],
20+
['create_bucket', { ...BUCKET, compartmentId: 'compartment' }],
21+
['update_bucket', { ...BUCKET, versioning: 'Enabled' }],
22+
['delete_bucket', BUCKET],
23+
['list_objects', BUCKET],
24+
['head_object', OBJECT],
25+
['upload_object', { ...OBJECT, content: '' }],
26+
['download_object', OBJECT],
27+
[
28+
'copy_object',
29+
{
30+
...OBJECT,
31+
destinationRegion: 'us-phoenix-1',
32+
destinationNamespace: 'namespace',
33+
destinationBucket: 'copies',
34+
destinationObjectName: 'copy',
35+
},
36+
],
37+
['rename_object', { ...OBJECT, newName: 'new' }],
38+
['delete_object', OBJECT],
39+
['batch_delete_objects', { ...BUCKET, objects: [{ objectName: 'report.txt' }] }],
40+
['list_object_versions', BUCKET],
41+
['restore_object', OBJECT],
42+
['update_object_storage_tier', { ...OBJECT, storageTier: 'Archive' }],
43+
['get_lifecycle_policy', BUCKET],
44+
['put_lifecycle_policy', { ...BUCKET, rules: [] }],
45+
['delete_lifecycle_policy', BUCKET],
46+
['create_multipart_upload', OBJECT],
47+
['upload_part', { ...MULTIPART, partNumber: 1, content: '' }],
48+
['list_multipart_uploads', BUCKET],
49+
['list_multipart_parts', MULTIPART],
50+
['commit_multipart_upload', { ...MULTIPART, partsToCommit: [{ partNum: 1, etag: 'etag' }] }],
51+
['abort_multipart_upload', MULTIPART],
52+
[
53+
'create_preauthenticated_request',
54+
{
55+
...OBJECT,
56+
name: 'Report',
57+
scope: 'object',
58+
accessType: 'ObjectRead',
59+
timeExpires: '2099-01-01T00:00:00Z',
60+
},
61+
],
62+
['list_preauthenticated_requests', BUCKET],
63+
['get_preauthenticated_request', { ...BUCKET, parId: 'par' }],
64+
['delete_preauthenticated_request', { ...BUCKET, parId: 'par' }],
65+
['get_work_request', { ...AUTH, workRequestId: 'work' }],
66+
]
67+
68+
function request(operation: string, input: unknown): InternalToolOperationCall {
69+
return {
70+
toolId: `oci_object_storage_native_${operation}`,
71+
input,
72+
headers: new Headers(),
73+
context: { workflowId: 'workflow', workspaceId: 'trusted-workspace', userId: 'actor' },
74+
requestId: 'request',
75+
}
76+
}
77+
78+
describe('native OCI tool operation handler', () => {
79+
beforeEach(() => {
80+
vi.clearAllMocks()
81+
mocks.executeOciNativeOperation.mockResolvedValue({ success: true, output: {} })
82+
})
83+
84+
it.each(CASES)(
85+
'validates and dispatches %s with trusted workspace context',
86+
async (operation, input) => {
87+
const result = await executeOciObjectStorageNativeTool(request(operation, input))
88+
expect(result.status).toBe(200)
89+
expect(mocks.executeOciNativeOperation).toHaveBeenCalledWith(
90+
expect.objectContaining({ ...input, operation }),
91+
{
92+
workspaceId: 'trusted-workspace',
93+
workflowId: 'workflow',
94+
executionId: undefined,
95+
userId: 'actor',
96+
requestId: 'request',
97+
signal: undefined,
98+
}
99+
)
100+
}
101+
)
102+
103+
it('maps the authorized hidden reference and strips the caller execution context', async () => {
104+
const input = createOciNativeOperationInput({
105+
oauthCredential: 'visible-selection',
106+
accessToken: 'authorized',
107+
_context: { workspaceId: 'attacker' },
108+
_credentialId: 'bookkeeping',
109+
_workflowId: 'workflow',
110+
credential: undefined,
111+
impersonateUserEmail: undefined,
112+
namespace: 'namespace',
113+
})
114+
expect(input).toEqual(AUTH)
115+
await executeOciObjectStorageNativeTool(request('get_namespace', input))
116+
expect(mocks.executeOciNativeOperation).toHaveBeenCalledWith(
117+
expect.objectContaining({ credentialId: 'authorized' }),
118+
expect.objectContaining({ workspaceId: 'trusted-workspace' })
119+
)
120+
const missing = createOciNativeOperationInput({ oauthCredential: 'visible-selection' })
121+
expect(
122+
(await executeOciObjectStorageNativeTool(request('get_namespace', missing))).status
123+
).toBe(400)
124+
})
125+
126+
it.each([
127+
{ ...AUTH, workspaceId: 'injected' },
128+
{ ...AUTH, operation: 'delete_bucket' },
129+
{ ...AUTH, authorization: 'injected' },
130+
])('rejects unexpected authority or operation fields', async (input) => {
131+
expect((await executeOciObjectStorageNativeTool(request('get_namespace', input))).status).toBe(
132+
400
133+
)
134+
expect(mocks.executeOciNativeOperation).not.toHaveBeenCalled()
135+
})
136+
137+
it('requires trusted workspace scope', async () => {
138+
const call = request('get_namespace', AUTH)
139+
call.context.workspaceId = undefined
140+
expect((await executeOciObjectStorageNativeTool(call)).status).toBe(403)
141+
expect(mocks.executeOciNativeOperation).not.toHaveBeenCalled()
142+
})
143+
144+
it('uses the delegated subject for file authorization', async () => {
145+
const call = request('upload_object', {
146+
...OBJECT,
147+
file: { key: 'file', name: 'file.txt', size: 0 },
148+
})
149+
call.context.executorDelegationOrigin = {
150+
subjectUserId: 'delegated-actor',
151+
workflowId: 'origin-workflow',
152+
executionId: 'origin-execution',
153+
}
154+
await executeOciObjectStorageNativeTool(call)
155+
expect(mocks.executeOciNativeOperation).toHaveBeenCalledWith(
156+
expect.anything(),
157+
expect.objectContaining({ userId: 'delegated-actor', workspaceId: 'trusted-workspace' })
158+
)
159+
})
160+
161+
it('projects safe foundation failures without exposing arbitrary error details', async () => {
162+
mocks.executeOciNativeOperation.mockRejectedValueOnce(
163+
new OciClientError('request_failed', { status: 412 })
164+
)
165+
const known = await executeOciObjectStorageNativeTool(request('get_namespace', AUTH))
166+
expect(known.status).toBe(412)
167+
await expect(known.json()).resolves.toEqual({ success: false, error: 'OCI request failed' })
168+
mocks.executeOciNativeOperation.mockRejectedValueOnce(
169+
new Error('private-key-or-storage-secret')
170+
)
171+
const unknown = await executeOciObjectStorageNativeTool(request('get_namespace', AUTH))
172+
await expect(unknown.json()).resolves.toEqual({
173+
success: false,
174+
error: 'OCI Object Storage operation failed',
175+
})
176+
})
177+
178+
it('preserves cancellation before and after provider work', async () => {
179+
const controller = new AbortController()
180+
const reason = new DOMException('Canceled', 'AbortError')
181+
const call = { ...request('get_namespace', AUTH), signal: controller.signal }
182+
mocks.executeOciNativeOperation.mockImplementationOnce(async () => {
183+
controller.abort(reason)
184+
throw reason
185+
})
186+
await expect(executeOciObjectStorageNativeTool(call)).rejects.toBe(reason)
187+
mocks.executeOciNativeOperation.mockClear()
188+
await expect(executeOciObjectStorageNativeTool(call)).rejects.toBe(reason)
189+
expect(mocks.executeOciNativeOperation).not.toHaveBeenCalled()
190+
})
191+
})
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
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 (
15+
!request.toolId.startsWith(PREFIX) ||
16+
!isPlainRecord(request.input) ||
17+
'operation' in request.input
18+
) {
19+
return Response.json(
20+
{ success: false, error: 'Invalid native OCI tool input' },
21+
{ status: 400 }
22+
)
23+
}
24+
const parsed = ociNativeInputSchema.safeParse({
25+
...request.input,
26+
operation: request.toolId.slice(PREFIX.length),
27+
})
28+
if (!parsed.success)
29+
return Response.json(
30+
{
31+
success: false,
32+
error: getValidationErrorMessage(parsed.error, 'Invalid native OCI request'),
33+
},
34+
{ status: 400 }
35+
)
36+
if (!request.context.workspaceId)
37+
return Response.json(
38+
{ success: false, error: 'Workspace context is required' },
39+
{ status: 403 }
40+
)
41+
try {
42+
const result = await executeOciNativeOperation(parsed.data, {
43+
workspaceId: request.context.workspaceId,
44+
workflowId: request.context.workflowId,
45+
executionId: request.context.executionId,
46+
userId: request.context.executorDelegationOrigin?.subjectUserId ?? request.context.userId,
47+
requestId: request.requestId,
48+
signal: request.signal,
49+
})
50+
request.signal?.throwIfAborted()
51+
return Response.json(result)
52+
} catch (error) {
53+
request.signal?.throwIfAborted()
54+
const normalized = normalizeOciNativeError(error)
55+
logger.warn('Native OCI operation failed', {
56+
requestId: request.requestId,
57+
toolId: request.toolId,
58+
status: normalized.status,
59+
})
60+
return Response.json(
61+
{ success: false, error: normalized.message },
62+
{ status: normalized.status }
63+
)
64+
}
65+
}

0 commit comments

Comments
 (0)