Skip to content

Commit deb8bef

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

49 files changed

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

apps/docs/content/docs/integrations/oci_queue.mdx

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

apps/sim/blocks/blocks/oci_queue.ts

Lines changed: 722 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 { OciQueueBlock, OciQueueBlockMeta } from '@/blocks/blocks/oci_queue'
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_queue: OciQueueBlock,
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_queue: OciQueueBlockMeta,
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_queue.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_queue: NetSuiteIcon,
468469
okta: OktaIcon,
469470
onedrive: MicrosoftOneDriveIcon,
470471
onepassword: OnePasswordIcon,
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import type { OciClient } from '@/lib/internal/oci/client.server'
2+
import {
3+
createOciDiscoveredEndpointPolicy,
4+
createOciStaticEndpointPolicy,
5+
type OciHostnameTemplate,
6+
type OciPreparedEndpoint,
7+
} from '@/lib/internal/oci/endpoints'
8+
import { OciClientError } from '@/lib/internal/oci/errors'
9+
10+
// Queue endpoint inventory: https://docs.oracle.com/en-us/iaas/api/specs/index.json
11+
const QUEUE_REGIONS = new Set([
12+
'af-casablanca-1', 'af-johannesburg-1', 'ap-batam-1', 'ap-chuncheon-1',
13+
'ap-hyderabad-1', 'ap-kulai-2', 'ap-melbourne-1', 'ap-mumbai-1', 'ap-osaka-1',
14+
'ap-seoul-1', 'ap-singapore-1', 'ap-singapore-2', 'ap-sydney-1', 'ap-tokyo-1',
15+
'ca-montreal-1', 'ca-toronto-1', 'eu-amsterdam-1', 'eu-frankfurt-1',
16+
'eu-jovanovac-1', 'eu-madrid-1', 'eu-madrid-3', 'eu-marseille-1', 'eu-milan-1',
17+
'eu-paris-1', 'eu-stockholm-1', 'eu-turin-1', 'eu-zurich-1', 'il-jerusalem-1',
18+
'me-abudhabi-1', 'me-dubai-1', 'me-jeddah-1', 'me-riyadh-1', 'mx-monterrey-1',
19+
'mx-queretaro-1', 'sa-bogota-1', 'sa-santiago-1', 'sa-saopaulo-1',
20+
'sa-valparaiso-1', 'sa-vinhedo-1', 'uk-cardiff-1', 'uk-london-1', 'us-ashburn-1',
21+
'us-chicago-1', 'us-phoenix-1', 'us-sanjose-1',
22+
])
23+
const REGIONAL_CONTROL_REGIONS = new Set([
24+
'ap-hyderabad-1', 'ap-kulai-2', 'mx-monterrey-1', 'mx-queretaro-1', 'us-chicago-1',
25+
])
26+
27+
function policies(hostnameTemplate: OciHostnameTemplate) {
28+
const control = createOciStaticEndpointPolicy({
29+
serviceId: 'oci-queue',
30+
serviceName: 'messaging',
31+
hostnameTemplate,
32+
})
33+
return {
34+
control,
35+
data: (['regional-oci', 'regional'] as const).map((template) =>
36+
createOciDiscoveredEndpointPolicy({
37+
serviceId: 'oci-queue',
38+
serviceName: 'queue.messaging',
39+
hostnameTemplate: template,
40+
responsePolicy: control,
41+
source: { kind: 'json', path: ['messagesEndpoint'] },
42+
})
43+
),
44+
}
45+
}
46+
47+
const regionalOciPolicies = policies('regional-oci')
48+
const regionalPolicies = policies('regional')
49+
50+
export interface PreparedOciQueueClient {
51+
client: OciClient
52+
control: OciPreparedEndpoint
53+
policies: ReturnType<typeof policies>
54+
}
55+
56+
export async function prepareOciQueueClient(client: OciClient): Promise<PreparedOciQueueClient> {
57+
// Preparation performs no request and exposes only the credential-bound effective region.
58+
const initial = await client.prepareStaticEndpoint(regionalOciPolicies.control)
59+
if (!QUEUE_REGIONS.has(initial.region.id)) throw new OciClientError('invalid_endpoint')
60+
const selected = REGIONAL_CONTROL_REGIONS.has(initial.region.id)
61+
? regionalPolicies
62+
: regionalOciPolicies
63+
const control = selected === regionalOciPolicies
64+
? initial
65+
: await client.prepareStaticEndpoint(selected.control)
66+
return { client, control, policies: selected }
67+
}
68+
69+
export async function discoverOciQueueEndpoint(
70+
prepared: PreparedOciQueueClient,
71+
queueId: string,
72+
signal?: AbortSignal
73+
): Promise<OciPreparedEndpoint> {
74+
signal?.throwIfAborted()
75+
const response = await prepared.client.request({
76+
endpoint: prepared.control,
77+
method: 'GET',
78+
encodedPath: `/20210201/queues/${encodeURIComponent(queueId)}`,
79+
timeoutMs: 10_000,
80+
maxResponseBytes: 1024 * 1024,
81+
retry: { kind: 'safe', maxAttempts: 2 },
82+
signal,
83+
})
84+
if (response.status !== 200) throw new OciClientError('invalid_endpoint')
85+
// Only these code-owned Queue host forms are accepted; neither attempt performs network I/O.
86+
for (const policy of prepared.policies.data) {
87+
try {
88+
return await prepared.client.prepareDiscoveredEndpoint(policy, response)
89+
} catch (error) {
90+
if (!(error instanceof OciClientError) || error.code !== 'invalid_endpoint') throw error
91+
}
92+
}
93+
throw new OciClientError('invalid_endpoint')
94+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/** @vitest-environment node */
2+
import { beforeEach, describe, expect, it, vi } from 'vitest'
3+
4+
const mocks = vi.hoisted(() => ({ authorize: vi.fn(), createClient: vi.fn(), prepare: vi.fn(), execute: vi.fn() }))
5+
vi.mock('@/lib/auth/credential-access', () => ({ authorizeCredentialUseForAuth: mocks.authorize }))
6+
vi.mock('@/lib/auth/hybrid', () => ({ AuthType: { INTERNAL_JWT: 'internal_jwt' } }))
7+
vi.mock('@/lib/internal/oci/client.server', () => ({ createOciClient: mocks.createClient }))
8+
vi.mock('@/lib/internal/oci-queue/endpoints', () => ({ prepareOciQueueClient: mocks.prepare }))
9+
vi.mock('@/lib/internal/oci-queue/operations', async (importOriginal) => ({
10+
...await importOriginal<typeof import('@/lib/internal/oci-queue/operations')>(),
11+
executeOciQueueOperation: mocks.execute,
12+
}))
13+
14+
import { OciClientError } from '@/lib/internal/oci/errors'
15+
import { executeOciQueueTool } from '@/lib/internal/oci-queue/execute-tool'
16+
import type { InternalToolOperationCall } from '@/lib/internal/tool-operations/types'
17+
18+
const call: InternalToolOperationCall = {
19+
toolId: 'oci_queue_get_messages',
20+
input: { oauthCredential: 'supplied-reference', queueId: 'queue', timeoutInSeconds: 0 },
21+
headers: new Headers(),
22+
context: { userId: 'actor', workspaceId: 'workspace', workflowId: 'workflow' },
23+
requestId: 'request',
24+
}
25+
26+
describe('OCI Queue internal adapter', () => {
27+
beforeEach(() => {
28+
vi.clearAllMocks()
29+
mocks.authorize.mockResolvedValue({ ok: true, resolvedCredentialId: 'resolved-id', credentialType: 'service_account', workspaceId: 'workspace' })
30+
mocks.createClient.mockResolvedValue('client')
31+
mocks.prepare.mockResolvedValue('prepared')
32+
mocks.execute.mockResolvedValue({ status: 200, messages: [] })
33+
})
34+
35+
it('passes the authoritative credential and trusted workspace through normal authorization', async () => {
36+
const signal = new AbortController().signal
37+
const response = await executeOciQueueTool({ ...call, signal, input: { ...call.input as object, workspaceId: 'untrusted', accessToken: 'placeholder', endpoint: 'https://attacker.example', region: 'us-phoenix-1' } })
38+
expect(response.status).toBe(200)
39+
expect(await response.json()).toEqual({ success: true, output: { status: 200, messages: [] } })
40+
expect(mocks.authorize).toHaveBeenCalledWith(
41+
{ success: true, userId: 'actor', authType: 'internal_jwt' },
42+
{ credentialId: 'supplied-reference', workspaceId: 'workspace', workflowId: 'workflow', callerUserId: 'actor' }
43+
)
44+
expect(mocks.createClient).toHaveBeenCalledWith({ credentialId: 'resolved-id', workspaceId: 'workspace', serviceId: 'oci-queue', region: 'us-phoenix-1' })
45+
expect(mocks.execute).toHaveBeenCalledWith({ operation: 'oci_queue_get_messages', oauthCredential: 'supplied-reference', queueId: 'queue', timeoutInSeconds: 0, region: 'us-phoenix-1' }, 'prepared', signal)
46+
})
47+
48+
it.each([
49+
{ ok: false },
50+
{ ok: true, credentialType: 'oauth', workspaceId: 'workspace', resolvedCredentialId: 'id' },
51+
{ ok: true, credentialType: 'service_account', workspaceId: 'other', resolvedCredentialId: 'id' },
52+
{ ok: true, credentialType: 'service_account', workspaceId: 'workspace' },
53+
])('rejects denied or mismatched authorization: %j', async (access) => {
54+
mocks.authorize.mockResolvedValue(access)
55+
expect((await executeOciQueueTool(call)).status).toBe(401)
56+
expect(mocks.createClient).not.toHaveBeenCalled()
57+
})
58+
59+
it('requires trusted actor and workspace context', async () => {
60+
expect((await executeOciQueueTool({ ...call, context: { workflowId: 'workflow' } })).status).toBe(401)
61+
expect(mocks.authorize).not.toHaveBeenCalled()
62+
})
63+
64+
it('validates the registered operation and input before credential work', async () => {
65+
expect((await executeOciQueueTool({ ...call, toolId: 'oci_queue_unknown' })).status).toBe(400)
66+
expect((await executeOciQueueTool({ ...call, input: { ...call.input as object, limit: 21 } })).status).toBe(400)
67+
expect(mocks.authorize).not.toHaveBeenCalled()
68+
})
69+
70+
it('does not let an input operation replace the dispatched tool', async () => {
71+
await executeOciQueueTool({ ...call, input: { ...call.input as object, operation: 'oci_queue_delete_queue' } })
72+
expect(mocks.execute.mock.calls[0][0].operation).toBe('oci_queue_get_messages')
73+
})
74+
75+
it('preserves foundation request errors and request IDs without retrying', async () => {
76+
mocks.execute.mockRejectedValueOnce(new OciClientError('request_failed', { status: 429, opcRequestId: 'oracle-request' }))
77+
const response = await executeOciQueueTool(call)
78+
expect(response.status).toBe(429)
79+
expect(await response.json()).toMatchObject({ success: false, retryable: false, output: { status: 429, requestId: 'oracle-request' } })
80+
expect(mocks.execute).toHaveBeenCalledTimes(1)
81+
})
82+
83+
it('keeps valid partial batches successful at the tool boundary', async () => {
84+
mocks.execute.mockResolvedValueOnce({ status: 200, allSucceeded: false, clientFailures: 1, serverFailures: 0, entries: [{ index: 0, success: false, errorCode: 400, errorMessage: 'Expired receipt' }] })
85+
const response = await executeOciQueueTool({ ...call, toolId: 'oci_queue_delete_messages', input: { oauthCredential: 'credential', queueId: 'queue', entries: [{ receipt: 'receipt' }] } })
86+
expect(response.status).toBe(200)
87+
expect(await response.json()).toMatchObject({ success: true, output: { allSucceeded: false } })
88+
})
89+
90+
it('forwards cancellation without replacing it with a functional response', async () => {
91+
const controller = new AbortController()
92+
mocks.execute.mockImplementationOnce(async () => { controller.abort(); throw controller.signal.reason })
93+
await expect(executeOciQueueTool({ ...call, signal: controller.signal })).rejects.toMatchObject({ name: 'AbortError' })
94+
expect(mocks.execute).toHaveBeenCalledTimes(1)
95+
})
96+
})
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { isPlainRecord } from '@sim/utils/object'
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 { prepareOciQueueClient } from '@/lib/internal/oci-queue/endpoints'
7+
import { executeOciQueueOperation, OciQueueOperationError } from '@/lib/internal/oci-queue/operations'
8+
import { ociQueueInputSchema } from '@/lib/internal/oci-queue/schema'
9+
import type { InternalToolOperationHandler } from '@/lib/internal/tool-operations/types'
10+
11+
function failure(error: string, status: number, requestId?: string): Response {
12+
return Response.json(
13+
{ success: false, error, output: { status, requestId }, retryable: false },
14+
{ status }
15+
)
16+
}
17+
18+
export const executeOciQueueTool: InternalToolOperationHandler = async ({ toolId, input, context, signal }) => {
19+
signal?.throwIfAborted()
20+
if (!context.userId || !context.workspaceId) return failure('Authentication required', 401)
21+
const parsed = ociQueueInputSchema.safeParse({ ...(isPlainRecord(input) ? input : {}), operation: toolId })
22+
if (!parsed.success) return failure(parsed.error.issues[0]?.message ?? 'Invalid OCI Queue input', 400)
23+
24+
try {
25+
const access = await authorizeCredentialUseForAuth(
26+
{ success: true, userId: context.userId, authType: AuthType.INTERNAL_JWT },
27+
{
28+
credentialId: parsed.data.oauthCredential,
29+
workspaceId: context.workspaceId,
30+
workflowId: context.workflowId || undefined,
31+
callerUserId: context.userId,
32+
}
33+
)
34+
if (
35+
!access.ok ||
36+
access.credentialType !== 'service_account' ||
37+
!access.resolvedCredentialId ||
38+
access.workspaceId !== context.workspaceId
39+
) {
40+
return failure('OCI Queue credential is unavailable', 401)
41+
}
42+
signal?.throwIfAborted()
43+
const client = await createOciClient({
44+
credentialId: access.resolvedCredentialId,
45+
workspaceId: context.workspaceId,
46+
serviceId: 'oci-queue',
47+
region: parsed.data.region,
48+
})
49+
const prepared = await prepareOciQueueClient(client)
50+
const output = await executeOciQueueOperation(parsed.data, prepared, signal)
51+
return Response.json({ success: true, output })
52+
} catch (error) {
53+
signal?.throwIfAborted()
54+
if (error instanceof OciQueueOperationError) return failure(error.message, error.status)
55+
if (error instanceof OciClientError) {
56+
const status = error.code === 'credential_unavailable' ? 401
57+
: error.code === 'invalid_request' || error.code === 'invalid_endpoint' ? 400
58+
: error.code === 'deadline_exceeded' ? 504
59+
: error.status && error.status >= 400 ? error.status : 502
60+
return failure(error.message, status, error.opcRequestId)
61+
}
62+
return failure('OCI Queue operation failed', 500)
63+
}
64+
}

0 commit comments

Comments
 (0)