Skip to content

Commit ac4c441

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
feat(oci-streaming): add native streaming integration
1 parent 3fa59e7 commit ac4c441

54 files changed

Lines changed: 5903 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_streaming: 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_streaming",
188189
"okta",
189190
"onedrive",
190191
"onepassword",

apps/docs/content/docs/integrations/oci_streaming.mdx

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

apps/sim/blocks/blocks/oci_streaming.ts

Lines changed: 608 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
@@ -249,6 +249,7 @@ import {
249249
NotionV2BlockMeta,
250250
} from '@/blocks/blocks/notion'
251251
import { ObsidianBlock, ObsidianBlockMeta } from '@/blocks/blocks/obsidian'
252+
import { OciStreamingBlock, OciStreamingBlockMeta } from '@/blocks/blocks/oci_streaming'
252253
import { OktaBlock, OktaBlockMeta } from '@/blocks/blocks/okta'
253254
import { OneDriveBlock, OneDriveBlockMeta } from '@/blocks/blocks/onedrive'
254255
import { OnePasswordBlock, OnePasswordBlockMeta } from '@/blocks/blocks/onepassword'
@@ -595,6 +596,7 @@ export const BLOCK_REGISTRY: Record<string, BlockConfig> = {
595596
notion: NotionBlock,
596597
notion_v2: NotionV2Block,
597598
obsidian: ObsidianBlock,
599+
oci_streaming: OciStreamingBlock,
598600
okta: OktaBlock,
599601
onedrive: OneDriveBlock,
600602
onepassword: OnePasswordBlock,
@@ -920,6 +922,7 @@ export const BLOCK_META_REGISTRY: Record<string, BlockMeta> = {
920922
notion: NotionBlockMeta,
921923
notion_v2: NotionV2BlockMeta,
922924
obsidian: ObsidianBlockMeta,
925+
oci_streaming: OciStreamingBlockMeta,
923926
okta: OktaBlockMeta,
924927
onedrive: OneDriveBlockMeta,
925928
onepassword: OnePasswordBlockMeta,

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_streaming.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_streaming: NetSuiteIcon,
468469
okta: OktaIcon,
469470
onedrive: MicrosoftOneDriveIcon,
470471
onepassword: OnePasswordIcon,
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
const mocks = vi.hoisted(() => ({
7+
authorize: vi.fn(),
8+
create: vi.fn(),
9+
prepare: vi.fn(),
10+
request: vi.fn(),
11+
}))
12+
vi.mock('@/lib/auth/credential-access', () => ({ authorizeCredentialUseForAuth: mocks.authorize }))
13+
vi.mock('@/lib/internal/oci/client.server', () => ({ createOciClient: mocks.create }))
14+
15+
import { AuthType } from '@/lib/auth/hybrid'
16+
import { executeOciStreamingTool } from '@/lib/internal/oci-streaming/execute-tool'
17+
import type { InternalToolOperationCall } from '@/lib/internal/tool-operations/types'
18+
19+
function call(overrides: Partial<InternalToolOperationCall> = {}): InternalToolOperationCall {
20+
return {
21+
toolId: 'oci_streaming_list_streams',
22+
input: {
23+
operation: 'list_streams',
24+
ociCredential: 'supplied-reference',
25+
compartmentId: 'compartment-1',
26+
ociRegion: 'us-ashburn-1',
27+
},
28+
context: { userId: 'actor-1', workspaceId: 'workspace-1', workflowId: 'workflow-1' },
29+
headers: new Headers(),
30+
requestId: 'request-1',
31+
...overrides,
32+
}
33+
}
34+
35+
describe('OCI Streaming trusted credential boundary', () => {
36+
beforeEach(() => {
37+
vi.clearAllMocks()
38+
mocks.authorize.mockResolvedValue({
39+
ok: true,
40+
credentialType: 'service_account',
41+
resolvedCredentialId: 'resolved-credential',
42+
workspaceId: 'workspace-1',
43+
})
44+
mocks.create.mockResolvedValue({ prepareStaticEndpoint: mocks.prepare, request: mocks.request })
45+
mocks.prepare.mockResolvedValue({
46+
origin: 'https://streaming.us-ashburn-1.oci.oraclecloud.com',
47+
})
48+
mocks.request.mockResolvedValue({
49+
status: 200,
50+
body: new TextEncoder().encode('[]'),
51+
headers: {},
52+
})
53+
})
54+
55+
it('passes only the resolved credential ID and trusted workspace to the foundation', async () => {
56+
const response = await executeOciStreamingTool(call())
57+
expect(response.status).toBe(200)
58+
expect(mocks.authorize).toHaveBeenCalledWith(
59+
{ success: true, userId: 'actor-1', authType: AuthType.INTERNAL_JWT },
60+
{
61+
credentialId: 'supplied-reference',
62+
workspaceId: 'workspace-1',
63+
workflowId: 'workflow-1',
64+
callerUserId: 'actor-1',
65+
}
66+
)
67+
expect(mocks.create).toHaveBeenCalledWith({
68+
credentialId: 'resolved-credential',
69+
workspaceId: 'workspace-1',
70+
serviceId: 'oci-streaming',
71+
region: 'us-ashburn-1',
72+
})
73+
})
74+
75+
it.each([
76+
{ ok: false },
77+
{
78+
ok: true,
79+
credentialType: 'oauth',
80+
resolvedCredentialId: 'resolved',
81+
workspaceId: 'workspace-1',
82+
},
83+
{ ok: true, credentialType: 'service_account', workspaceId: 'workspace-1' },
84+
{
85+
ok: true,
86+
credentialType: 'service_account',
87+
resolvedCredentialId: 'resolved',
88+
workspaceId: 'other-workspace',
89+
},
90+
])('starts no provider work for unauthorized credential resolution', async (access) => {
91+
mocks.authorize.mockResolvedValue(access)
92+
expect((await executeOciStreamingTool(call())).status).toBe(403)
93+
expect(mocks.create).not.toHaveBeenCalled()
94+
expect(mocks.request).not.toHaveBeenCalled()
95+
})
96+
97+
it.each([
98+
{
99+
freeformTags: Object.fromEntries(Array.from({ length: 11 }, (_, i) => [String(i), 'value'])),
100+
},
101+
{
102+
definedTags: {
103+
namespace: Object.fromEntries(Array.from({ length: 65 }, (_, i) => [String(i), 'value'])),
104+
},
105+
},
106+
{ freeformTags: { key: 'x'.repeat(257) } },
107+
{
108+
definedTags: {
109+
namespace: Object.fromEntries(
110+
Array.from({ length: 20 }, (_, i) => [String(i), 'é'.repeat(256)])
111+
),
112+
},
113+
},
114+
])(
115+
'rejects oversized administrative tags before authorization or provider work',
116+
async (tags) => {
117+
const response = await executeOciStreamingTool(
118+
call({
119+
toolId: 'oci_streaming_update_stream',
120+
input: {
121+
operation: 'update_stream',
122+
ociCredential: 'credential',
123+
streamId: 'stream',
124+
...tags,
125+
},
126+
})
127+
)
128+
expect(response.status).toBe(400)
129+
expect(mocks.authorize).not.toHaveBeenCalled()
130+
expect(mocks.create).not.toHaveBeenCalled()
131+
expect(mocks.request).not.toHaveBeenCalled()
132+
}
133+
)
134+
135+
it('rejects forged context in operation input before authorization', async () => {
136+
const request = call()
137+
request.input = {
138+
operation: 'list_streams',
139+
ociCredential: 'credential',
140+
compartmentId: 'compartment',
141+
workspaceId: 'forged',
142+
_context: { userId: 'forged' },
143+
}
144+
expect((await executeOciStreamingTool(request)).status).toBe(400)
145+
expect(mocks.authorize).not.toHaveBeenCalled()
146+
expect(mocks.create).not.toHaveBeenCalled()
147+
})
148+
149+
it('rejects missing trusted identity and mismatched operation IDs', async () => {
150+
expect((await executeOciStreamingTool(call({ context: { workflowId: '' } }))).status).toBe(401)
151+
expect((await executeOciStreamingTool(call({ toolId: 'oci_streaming_delete_stream' }))).status).toBe(400)
152+
expect(mocks.authorize).not.toHaveBeenCalled()
153+
})
154+
155+
it('does not load credentials after cancellation while authorization is pending', async () => {
156+
const controller = new AbortController()
157+
let finish: ((value: unknown) => void) | undefined
158+
mocks.authorize.mockImplementation(() => new Promise((resolve) => { finish = resolve }))
159+
const pending = executeOciStreamingTool(call({ signal: controller.signal }))
160+
controller.abort(new DOMException('Canceled', 'AbortError'))
161+
await pending
162+
finish?.({ ok: true, credentialType: 'service_account', resolvedCredentialId: 'resolved', workspaceId: 'workspace-1' })
163+
await Promise.resolve()
164+
expect(mocks.create).not.toHaveBeenCalled()
165+
})
166+
})
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { getErrorMessage } from '@sim/utils/errors'
2+
import { authorizeCredentialUseForAuth } from '@/lib/auth/credential-access'
3+
import { AuthType } from '@/lib/auth/hybrid'
4+
import { createOciClient } from '@/lib/internal/oci/client.server'
5+
import { OciClientError } from '@/lib/internal/oci/errors'
6+
import {
7+
awaitOciStreaming,
8+
executeOciStreamingOperation,
9+
OCI_STREAMING_ADMIN_ENDPOINT,
10+
OCI_STREAMING_SERVICE_ID,
11+
withOciStreamingBudget,
12+
} from '@/lib/internal/oci-streaming/operations'
13+
import { ociStreamingInputSchema } from '@/lib/internal/oci-streaming/schema'
14+
import type { InternalToolOperationHandler } from '@/lib/internal/tool-operations/types'
15+
16+
export const executeOciStreamingTool: InternalToolOperationHandler = async (request) => {
17+
const parsed = ociStreamingInputSchema.safeParse(request.input)
18+
if (!parsed.success) {
19+
return Response.json(
20+
{ success: false, error: parsed.error.issues.map((issue) => issue.message).join('; ') },
21+
{ status: 400 }
22+
)
23+
}
24+
const input = parsed.data
25+
if (request.toolId !== `oci_streaming_${input.operation}`) {
26+
return Response.json(
27+
{ success: false, error: 'OCI Streaming operation does not match tool' },
28+
{ status: 400 }
29+
)
30+
}
31+
const { userId, workspaceId, workflowId } = request.context
32+
if (!userId || !workspaceId) {
33+
return Response.json(
34+
{ success: false, error: 'Trusted user and workspace context are required' },
35+
{ status: 401 }
36+
)
37+
}
38+
try {
39+
return await withOciStreamingBudget(async (budget) => {
40+
const access = await awaitOciStreaming(
41+
authorizeCredentialUseForAuth(
42+
{ success: true, userId, authType: AuthType.INTERNAL_JWT },
43+
{
44+
credentialId: input.ociCredential,
45+
workspaceId,
46+
workflowId: workflowId || undefined,
47+
callerUserId: userId,
48+
}
49+
),
50+
budget.signal
51+
)
52+
if (
53+
!access.ok ||
54+
access.credentialType !== 'service_account' ||
55+
!access.resolvedCredentialId ||
56+
access.workspaceId !== workspaceId
57+
) {
58+
return Response.json(
59+
{ success: false, error: 'OCI service account is not accessible in this workspace' },
60+
{ status: 403 }
61+
)
62+
}
63+
budget.signal.throwIfAborted()
64+
const client = await awaitOciStreaming(
65+
createOciClient({
66+
credentialId: access.resolvedCredentialId,
67+
workspaceId,
68+
serviceId: OCI_STREAMING_SERVICE_ID,
69+
region: input.ociRegion,
70+
}),
71+
budget.signal
72+
)
73+
budget.signal.throwIfAborted()
74+
const endpoint = await awaitOciStreaming(
75+
client.prepareStaticEndpoint(OCI_STREAMING_ADMIN_ENDPOINT),
76+
budget.signal
77+
)
78+
budget.signal.throwIfAborted()
79+
const result = await executeOciStreamingOperation(input, { client, endpoint }, budget)
80+
return Response.json(result)
81+
}, request.signal)
82+
} catch (error) {
83+
if (error instanceof OciClientError) {
84+
return Response.json(
85+
{
86+
success: false,
87+
error: error.message,
88+
output: {
89+
status: error.status ?? null,
90+
requestId: error.opcRequestId ?? null,
91+
code: error.code,
92+
},
93+
},
94+
{ status: error.status && error.status >= 400 ? error.status : 502 }
95+
)
96+
}
97+
return Response.json(
98+
{ success: false, error: getErrorMessage(error, 'OCI Streaming operation failed') },
99+
{ status: 400 }
100+
)
101+
}
102+
}

0 commit comments

Comments
 (0)