Skip to content

Commit 78fb7a0

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
feat(oci-document): add native Document Understanding integration
1 parent 3fa59e7 commit 78fb7a0

47 files changed

Lines changed: 3807 additions & 8 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_document_understanding: 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_document_understanding",
188189
"okta",
189190
"onedrive",
190191
"onepassword",

apps/docs/content/docs/integrations/oci_document_understanding.mdx

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

apps/sim/blocks/blocks/oci_document_understanding.ts

Lines changed: 254 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+
OciDocumentUnderstandingBlock,
254+
OciDocumentUnderstandingBlockMeta,
255+
} from '@/blocks/blocks/oci_document_understanding'
252256
import { OktaBlock, OktaBlockMeta } from '@/blocks/blocks/okta'
253257
import { OneDriveBlock, OneDriveBlockMeta } from '@/blocks/blocks/onedrive'
254258
import { OnePasswordBlock, OnePasswordBlockMeta } from '@/blocks/blocks/onepassword'
@@ -595,6 +599,7 @@ export const BLOCK_REGISTRY: Record<string, BlockConfig> = {
595599
notion: NotionBlock,
596600
notion_v2: NotionV2Block,
597601
obsidian: ObsidianBlock,
602+
oci_document_understanding: OciDocumentUnderstandingBlock,
598603
okta: OktaBlock,
599604
onedrive: OneDriveBlock,
600605
onepassword: OnePasswordBlock,
@@ -920,6 +925,7 @@ export const BLOCK_META_REGISTRY: Record<string, BlockMeta> = {
920925
notion: NotionBlockMeta,
921926
notion_v2: NotionV2BlockMeta,
922927
obsidian: ObsidianBlockMeta,
928+
oci_document_understanding: OciDocumentUnderstandingBlockMeta,
923929
okta: OktaBlockMeta,
924930
onedrive: OneDriveBlockMeta,
925931
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_document_understanding.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_document_understanding: NetSuiteIcon,
468469
okta: OktaIcon,
469470
onedrive: MicrosoftOneDriveIcon,
470471
onepassword: OnePasswordIcon,
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { createOciClient, type OciAuthenticatedResponse } from '@/lib/internal/oci/client.server'
2+
import { createOciStaticEndpointPolicy } from '@/lib/internal/oci/endpoints'
3+
import { DocumentOperationError } from '@/lib/internal/oci-document-understanding/errors'
4+
import { isDocumentJsonWithinLimit } from '@/tools/oci_document_understanding/shared'
5+
6+
const documentPolicy = createOciStaticEndpointPolicy({
7+
serviceId: 'oci_document_understanding',
8+
serviceName: 'document.aiservice',
9+
hostnameTemplate: 'regional-oci',
10+
})
11+
const storagePolicy = createOciStaticEndpointPolicy({
12+
serviceId: 'oci_document_understanding',
13+
serviceName: 'objectstorage',
14+
hostnameTemplate: 'regional',
15+
})
16+
17+
export async function prepareDocumentClient(
18+
input: { credentialId: string; region?: string },
19+
workspaceId: string
20+
) {
21+
if (!workspaceId) throw new DocumentOperationError('Workspace context is required', 403)
22+
const client = await createOciClient({
23+
credentialId: input.credentialId,
24+
region: input.region,
25+
workspaceId,
26+
serviceId: 'oci_document_understanding',
27+
})
28+
return {
29+
client,
30+
endpoint: await client.prepareStaticEndpoint(documentPolicy),
31+
storage: await client.prepareStaticEndpoint(storagePolicy),
32+
}
33+
}
34+
35+
export type PreparedDocumentClient = Awaited<ReturnType<typeof prepareDocumentClient>>
36+
37+
export function documentPath(value: string) {
38+
return encodeURIComponent(value).replace(
39+
/[!'()*]/g,
40+
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`
41+
)
42+
}
43+
44+
export function documentJsonBody(value: unknown, limit: number) {
45+
if (!isDocumentJsonWithinLimit(value, limit))
46+
throw new DocumentOperationError('Document request exceeds its byte limit', 413)
47+
return new Uint8Array(Buffer.from(JSON.stringify(value), 'utf8'))
48+
}
49+
50+
export function parseDocumentJson(response: OciAuthenticatedResponse): unknown {
51+
try {
52+
return JSON.parse(Buffer.from(response.body).toString('utf8'))
53+
} catch {
54+
throw new DocumentOperationError('Unexpected Document Understanding JSON response', 502)
55+
}
56+
}
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
const {
7+
authorize,
8+
principal,
9+
provenance,
10+
safeKey,
11+
safeContributor,
12+
download,
13+
openPdf,
14+
destroyPdf,
15+
imageMetadata,
16+
} = vi.hoisted(() => ({
17+
authorize: vi.fn(),
18+
principal: vi.fn(),
19+
provenance: vi.fn(),
20+
safeKey: vi.fn(),
21+
safeContributor: vi.fn(),
22+
download: vi.fn(),
23+
openPdf: vi.fn(),
24+
destroyPdf: vi.fn(),
25+
imageMetadata: vi.fn(),
26+
}))
27+
vi.mock('@/lib/execution/payloads/materialization.server', () => ({
28+
assertUserFileContentAccess: authorize,
29+
}))
30+
vi.mock('@/lib/internal/principals/executor', () => ({
31+
createExecutorPrincipalFromExecutionContext: principal,
32+
}))
33+
vi.mock('@sim/auth/principal', () => ({
34+
resolvePrincipalSubject: () => ({ kind: 'sim_user', userId: 'actor-1' }),
35+
}))
36+
vi.mock('@/lib/execution/model-input-provenance', () => ({
37+
validateOpaqueModelInputProvenance: provenance,
38+
}))
39+
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-secret-provenance', () => ({
40+
isModelSafeWorkspaceFileKey: safeKey,
41+
isOpaqueWorkspaceFileEgressSafe: safeContributor,
42+
MODEL_UNSAFE_WORKSPACE_FILE_ERROR_MESSAGE: 'Unsafe workspace file',
43+
}))
44+
vi.mock('@/lib/uploads/utils/file-utils.server', () => ({
45+
downloadServableFileFromStorage: download,
46+
}))
47+
vi.mock('@/lib/file-parsers/pdfjs-server', () => ({ openPdfDocument: openPdf }))
48+
vi.mock('sharp', () => ({ default: () => ({ metadata: imageMetadata }) }))
49+
vi.mock('@/lib/workspace-files/application/authorization', () => ({
50+
WORKSPACE_FILES_DELEGATION_AUDIENCE: 'workspace-files',
51+
}))
52+
53+
import {
54+
prepareDocumentSource,
55+
validateDocumentBytes,
56+
} from '@/lib/internal/oci-document-understanding/document-input'
57+
import {
58+
type AnalysisInput,
59+
documentInputSchema,
60+
} from '@/lib/internal/oci-document-understanding/schema'
61+
import type { InternalToolOperationCall } from '@/lib/internal/tool-operations/types'
62+
63+
const file = {
64+
id: 'file-1',
65+
name: 'invoice.pdf',
66+
key: 'workspace/workspace-1/file-1',
67+
url: 'https://untrusted.example/ignored',
68+
size: 12,
69+
type: 'application/pdf',
70+
}
71+
const call: InternalToolOperationCall = {
72+
toolId: 'oci_document_understanding_analyze_document',
73+
requestId: 'request-1',
74+
headers: new Headers(),
75+
context: {
76+
workspaceId: 'workspace-1',
77+
workflowId: 'workflow-1',
78+
executionId: 'execution-1',
79+
userId: 'owner-not-actor',
80+
},
81+
}
82+
function input(values: Record<string, unknown> = {}): AnalysisInput {
83+
const parsed = documentInputSchema.parse({
84+
operation: 'analyze_document',
85+
credentialId: 'authorized',
86+
source: 'file',
87+
file,
88+
features: [{ featureType: 'TEXT_EXTRACTION' }],
89+
...values,
90+
})
91+
if (parsed.operation !== 'analyze_document' && parsed.operation !== 'create_processor_job')
92+
throw new Error('Expected analysis')
93+
return parsed
94+
}
95+
96+
describe('authorized document inputs', () => {
97+
beforeEach(() => {
98+
vi.clearAllMocks()
99+
principal.mockResolvedValue({ kind: 'session', userId: 'actor-1', sessionId: 'session-1' })
100+
provenance.mockReturnValue({ success: true })
101+
authorize.mockResolvedValue(undefined)
102+
safeKey.mockResolvedValue(true)
103+
safeContributor.mockResolvedValue(true)
104+
download.mockResolvedValue({ buffer: Buffer.from('%PDF-synthetic') })
105+
openPdf.mockResolvedValue({ numPages: 1, destroy: destroyPdf })
106+
imageMetadata.mockResolvedValue({ format: 'png', width: 100, height: 100 })
107+
})
108+
109+
it('authorizes the stored file with the acting principal and bounds the shared download', async () => {
110+
const result = await prepareDocumentSource(input(), call)
111+
expect(authorize).toHaveBeenCalledWith(
112+
file,
113+
expect.objectContaining({ userId: 'actor-1', workspaceId: 'workspace-1' })
114+
)
115+
expect(safeKey).toHaveBeenCalledWith(file.key, {
116+
workspaceId: 'workspace-1',
117+
actorUserId: 'actor-1',
118+
})
119+
expect(download).toHaveBeenCalledWith(
120+
file,
121+
'request-1',
122+
expect.anything(),
123+
expect.objectContaining({
124+
maxBytes: 8_000_000,
125+
filePrincipal: expect.objectContaining({ userId: 'actor-1' }),
126+
})
127+
)
128+
expect(result).toEqual({
129+
source: 'INLINE',
130+
data: Buffer.from('%PDF-synthetic').toString('base64'),
131+
})
132+
expect(destroyPdf).toHaveBeenCalledOnce()
133+
})
134+
135+
it('rejects opaque secret provenance before touching a file or Oracle object', async () => {
136+
provenance.mockReturnValue({ success: false, status: 400, error: 'Unsafe model input' })
137+
await expect(prepareDocumentSource(input(), call)).rejects.toThrow('Unsafe model input')
138+
expect(authorize).not.toHaveBeenCalled()
139+
expect(download).not.toHaveBeenCalled()
140+
})
141+
142+
it.each(['authorization', 'provenance'])('denies %s failures before reading bytes', async (kind) => {
143+
if (kind === 'authorization') authorize.mockRejectedValue(new Error('private detail'))
144+
else safeKey.mockResolvedValue(false)
145+
await expect(prepareDocumentSource(input(), call)).rejects.toThrow(kind === 'authorization' ? 'File is not available' : 'Unsafe workspace file')
146+
expect(download).not.toHaveBeenCalled()
147+
})
148+
149+
it('rejects unsafe contributing files after authorized materialization', async () => {
150+
const contributor = { kind: 'workspace_file', fileId: 'contributor-1' }
151+
download.mockResolvedValue({ buffer: Buffer.from('%PDF-synthetic'), contributingFiles: [contributor] })
152+
safeContributor.mockResolvedValue(false)
153+
await expect(prepareDocumentSource(input(), call)).rejects.toThrow('Unsafe workspace file')
154+
expect(safeContributor).toHaveBeenCalledWith('workspace-1', contributor)
155+
expect(openPdf).not.toHaveBeenCalled()
156+
})
157+
158+
it('uses Oracle namespace/bucket/object locations without treating them as Sim files', async () => {
159+
const objects = [{ namespaceName: 'namespace', bucketName: 'bucket', objectName: 'exact/a b.pdf', pageRange: ['1-3'] }]
160+
const sync = await prepareDocumentSource(input({ source: 'objectStorage', file: undefined, objects }), call)
161+
expect(sync).toEqual({ source: 'OBJECT_STORAGE', ...objects[0] })
162+
const batch = await prepareDocumentSource(input({
163+
operation: 'create_processor_job', source: 'objectStorage', file: undefined, objects,
164+
compartmentId: 'compartment-1', outputLocation: { namespaceName: 'namespace', bucketName: 'results', prefix: 'docs' },
165+
}), call)
166+
expect(batch).toEqual({ sourceType: 'OBJECT_STORAGE_LOCATIONS', objectLocations: objects })
167+
expect(provenance).toHaveBeenCalledTimes(2)
168+
expect(download).not.toHaveBeenCalled()
169+
expect(authorize).not.toHaveBeenCalled()
170+
})
171+
172+
it('rejects URL-only and inline-base64 inputs at the boundary', () => {
173+
expect(() => input({ file: { url: 'https://example.com/private.pdf' } })).toThrow()
174+
expect(() => input({ file: { ...file, base64: 'raw-document' } })).toThrow()
175+
expect(() => input({ file: { ...file, providerFileId: 'file-external' } })).toThrow()
176+
})
177+
178+
it('enforces actual byte and page limits rather than trusting file metadata', async () => {
179+
await expect(validateDocumentBytes(Buffer.alloc(8_000_001))).rejects.toThrow('8,000,000')
180+
openPdf.mockResolvedValue({ numPages: 6, destroy: destroyPdf })
181+
await expect(validateDocumentBytes(Buffer.from('%PDF-synthetic'))).rejects.toThrow('five pages')
182+
expect(destroyPdf).toHaveBeenCalledOnce()
183+
imageMetadata.mockResolvedValue({ format: 'tiff', pages: 6, width: 100, height: 100 })
184+
await expect(validateDocumentBytes(Buffer.from('synthetic TIFF'))).rejects.toThrow('five pages')
185+
imageMetadata.mockResolvedValue({ format: 'png', width: 10001, height: 100 })
186+
await expect(validateDocumentBytes(Buffer.from('synthetic PNG'))).rejects.toThrow('pixels')
187+
imageMetadata.mockResolvedValue({ format: 'webp', width: 100, height: 100 })
188+
await expect(validateDocumentBytes(Buffer.from('synthetic WebP'))).rejects.toThrow('Only JPEG')
189+
})
190+
})

0 commit comments

Comments
 (0)