chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,81 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { normalizeExpenseDocuments } from '@/app/api/tools/textract/analyze-expense/route'
describe('normalizeExpenseDocuments', () => {
it('maps a documented AWS AnalyzeExpense response shape', () => {
const result = normalizeExpenseDocuments([
{
ExpenseIndex: 1,
SummaryFields: [
{
Type: { Text: 'VENDOR_NAME', Confidence: 98.1 },
ValueDetection: { Text: 'Acme Corp', Confidence: 97.5 },
LabelDetection: { Text: 'Vendor', Confidence: 90 },
PageNumber: 1,
Currency: { Code: 'USD', Confidence: 95 },
GroupProperties: [{ Id: 'g1', Types: ['VENDOR'] }],
},
],
LineItemGroups: [
{
LineItemGroupIndex: 1,
LineItems: [
{
LineItemExpenseFields: [
{
Type: { Text: 'ITEM', Confidence: 91 },
ValueDetection: { Text: 'Widget', Confidence: 93 },
},
],
},
],
},
],
},
])
expect(result).toEqual([
{
expenseIndex: 1,
summaryFields: [
{
type: { text: 'VENDOR_NAME', confidence: 98.1 },
valueDetection: { text: 'Acme Corp', confidence: 97.5 },
labelDetection: { text: 'Vendor', confidence: 90 },
pageNumber: 1,
currency: { code: 'USD', confidence: 95 },
groupProperties: [{ id: 'g1', types: ['VENDOR'] }],
},
],
lineItemGroups: [
{
lineItemGroupIndex: 1,
lineItems: [
{
lineItemExpenseFields: [
{
type: { text: 'ITEM', confidence: 91 },
valueDetection: { text: 'Widget', confidence: 93 },
labelDetection: undefined,
pageNumber: undefined,
currency: undefined,
groupProperties: undefined,
},
],
},
],
},
],
},
])
})
it('defaults missing arrays to empty arrays', () => {
expect(normalizeExpenseDocuments([{ ExpenseIndex: 0 }])).toEqual([
{ expenseIndex: 0, summaryFields: [], lineItemGroups: [] },
])
})
})
@@ -0,0 +1,218 @@
import {
AnalyzeExpenseCommand,
type ExpenseDocument,
GetExpenseAnalysisCommand,
StartExpenseAnalysisCommand,
TextractClient,
} from '@aws-sdk/client-textract'
import { createLogger } from '@sim/logger'
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { textractAnalyzeExpenseContract } from '@/lib/api/contracts/tools/media/document-parse'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
mapTextractSdkError,
parseS3Uri,
pollTextractJob,
resolveDocumentInput,
textractErrorResponse,
} from '@/app/api/tools/textract/shared'
export const dynamic = 'force-dynamic'
/** Mirrors maxDuration in ../parse/route.ts — see that file's TSDoc for details. */
export const maxDuration = 5400
const logger = createLogger('TextractAnalyzeExpenseAPI')
/** Response shape shared by AnalyzeExpense and its async Get* counterpart. */
interface TextractExpenseResult {
JobStatus?: string
StatusMessage?: string
NextToken?: string
ExpenseDocuments?: ExpenseDocument[]
DocumentMetadata?: { Pages?: number }
AnalyzeExpenseModelVersion?: string
}
export function normalizeExpenseField(field: {
Type?: { Text?: string; Confidence?: number }
ValueDetection?: { Text?: string; Confidence?: number }
LabelDetection?: { Text?: string; Confidence?: number }
PageNumber?: number
Currency?: { Code?: string; Confidence?: number }
GroupProperties?: { Id?: string; Types?: string[] }[]
}) {
return {
type: { text: field.Type?.Text, confidence: field.Type?.Confidence },
valueDetection: {
text: field.ValueDetection?.Text,
confidence: field.ValueDetection?.Confidence,
},
labelDetection: field.LabelDetection
? { text: field.LabelDetection.Text, confidence: field.LabelDetection.Confidence }
: undefined,
pageNumber: field.PageNumber,
currency: field.Currency
? { code: field.Currency.Code, confidence: field.Currency.Confidence }
: undefined,
groupProperties: field.GroupProperties?.map((group) => ({
id: group.Id ?? '',
types: group.Types ?? [],
})),
}
}
export function normalizeExpenseDocuments(documents: ExpenseDocument[]) {
return documents.map((doc) => ({
expenseIndex: doc.ExpenseIndex,
summaryFields: (doc.SummaryFields ?? []).map(normalizeExpenseField),
lineItemGroups: (doc.LineItemGroups ?? []).map((group) => ({
lineItemGroupIndex: group.LineItemGroupIndex,
lineItems: (group.LineItems ?? []).map((item) => ({
lineItemExpenseFields: (item.LineItemExpenseFields ?? []).map(normalizeExpenseField),
})),
})),
}))
}
export const POST = withRouteHandler(async (request: NextRequest) => {
const requestId = generateRequestId()
try {
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
logger.warn(`[${requestId}] Unauthorized Textract analyze-expense attempt`, {
error: authResult.error || 'Missing userId',
})
return NextResponse.json(
{ success: false, error: authResult.error || 'Unauthorized' },
{ status: 401 }
)
}
const userId = authResult.userId
const parsed = await parseRequest(
textractAnalyzeExpenseContract,
request,
{},
{
validationErrorResponse: (error) => {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.issues })
return NextResponse.json(
{
success: false,
error: getValidationErrorMessage(error, 'Invalid request data'),
details: error.issues,
},
{ status: 400 }
)
},
}
)
if (!parsed.success) return parsed.response
const validatedData = parsed.data.body
const processingMode = validatedData.processingMode || 'sync'
logger.info(`[${requestId}] Textract analyze-expense request`, {
processingMode,
hasFile: Boolean(validatedData.file),
hasS3Uri: Boolean(validatedData.s3Uri),
userId,
})
const client = new TextractClient({
region: validatedData.region,
credentials: {
accessKeyId: validatedData.accessKeyId,
secretAccessKey: validatedData.secretAccessKey,
},
})
if (processingMode === 'async') {
if (!validatedData.s3Uri) {
return NextResponse.json(
{
success: false,
error: 'S3 URI is required for multi-page processing (s3://bucket/key)',
},
{ status: 400 }
)
}
const { bucket, key } = parseS3Uri(validatedData.s3Uri)
logger.info(`[${requestId}] Starting async Textract expense analysis job`, {
s3Bucket: bucket,
s3Key: key,
})
const { JobId: jobId } = await client.send(
new StartExpenseAnalysisCommand({
DocumentLocation: { S3Object: { Bucket: bucket, Name: key } },
})
)
if (!jobId) {
throw new Error('Failed to start Textract expense analysis job: No JobId returned')
}
logger.info(`[${requestId}] Async expense analysis job started`, { jobId })
const result = await pollTextractJob<TextractExpenseResult>(
requestId,
logger,
(nextToken) =>
client.send(new GetExpenseAnalysisCommand({ JobId: jobId, NextToken: nextToken })),
(accumulated, page) => ({
...accumulated,
...page,
ExpenseDocuments: [
...(accumulated.ExpenseDocuments ?? []),
...(page.ExpenseDocuments ?? []),
],
})
)
return NextResponse.json({
success: true,
output: {
expenseDocuments: normalizeExpenseDocuments(result.ExpenseDocuments ?? []),
documentMetadata: { pages: result.DocumentMetadata?.Pages ?? 0 },
modelVersion: result.AnalyzeExpenseModelVersion,
},
})
}
const resolved = await resolveDocumentInput(
{ file: validatedData.file, filePath: validatedData.filePath },
userId,
requestId,
logger
)
if (!resolved.ok) return resolved.response
const { bytes, isPdf } = resolved.document
let result: TextractExpenseResult
try {
result = await client.send(new AnalyzeExpenseCommand({ Document: { Bytes: bytes } }))
} catch (error) {
throw mapTextractSdkError(error, isPdf)
}
logger.info(`[${requestId}] Textract analyze-expense successful`, {
pageCount: result.DocumentMetadata?.Pages ?? 0,
expenseDocumentCount: result.ExpenseDocuments?.length ?? 0,
})
return NextResponse.json({
success: true,
output: {
expenseDocuments: normalizeExpenseDocuments(result.ExpenseDocuments ?? []),
documentMetadata: { pages: result.DocumentMetadata?.Pages ?? 0 },
},
})
} catch (error) {
return textractErrorResponse(error, requestId, logger)
}
})
@@ -0,0 +1,63 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { normalizeIdentityDocuments } from '@/app/api/tools/textract/analyze-id/route'
describe('normalizeIdentityDocuments', () => {
it('maps a documented AWS AnalyzeID response shape', () => {
const result = normalizeIdentityDocuments([
{
DocumentIndex: 1,
IdentityDocumentFields: [
{
Type: { Text: 'FIRST_NAME', Confidence: 99 },
ValueDetection: { Text: 'Jane', Confidence: 98 },
},
{
Type: {
Text: 'DATE_OF_BIRTH',
Confidence: 97,
NormalizedValue: { Value: '1990-01-01', ValueType: 'Date' },
},
ValueDetection: {
Text: '01/01/1990',
Confidence: 96,
NormalizedValue: { Value: '1990-01-01T00:00:00', ValueType: 'Date' },
},
},
],
},
])
expect(result).toEqual([
{
documentIndex: 1,
identityDocumentFields: [
{
type: { text: 'FIRST_NAME', confidence: 99, normalizedValue: undefined },
valueDetection: { text: 'Jane', confidence: 98, normalizedValue: undefined },
},
{
type: {
text: 'DATE_OF_BIRTH',
confidence: 97,
normalizedValue: { value: '1990-01-01', valueType: 'Date' },
},
valueDetection: {
text: '01/01/1990',
confidence: 96,
normalizedValue: { value: '1990-01-01T00:00:00', valueType: 'Date' },
},
},
],
},
])
})
it('defaults missing fields to an empty array', () => {
expect(normalizeIdentityDocuments([{ DocumentIndex: 0 }])).toEqual([
{ documentIndex: 0, identityDocumentFields: [] },
])
})
})
@@ -0,0 +1,150 @@
import { AnalyzeIDCommand, type IdentityDocument, TextractClient } from '@aws-sdk/client-textract'
import { createLogger } from '@sim/logger'
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { textractAnalyzeIdContract } from '@/lib/api/contracts/tools/media/document-parse'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
mapTextractSdkError,
resolveDocumentInput,
textractErrorResponse,
} from '@/app/api/tools/textract/shared'
export const dynamic = 'force-dynamic'
const logger = createLogger('TextractAnalyzeIdAPI')
export function normalizeIdentityDocuments(documents: IdentityDocument[]) {
return documents.map((doc) => ({
documentIndex: doc.DocumentIndex,
identityDocumentFields: (doc.IdentityDocumentFields ?? []).map((field) => ({
type: {
text: field.Type?.Text,
confidence: field.Type?.Confidence,
normalizedValue: field.Type?.NormalizedValue
? {
value: field.Type.NormalizedValue.Value,
valueType: field.Type.NormalizedValue.ValueType,
}
: undefined,
},
valueDetection: {
text: field.ValueDetection?.Text,
confidence: field.ValueDetection?.Confidence,
normalizedValue: field.ValueDetection?.NormalizedValue
? {
value: field.ValueDetection.NormalizedValue.Value,
valueType: field.ValueDetection.NormalizedValue.ValueType,
}
: undefined,
},
})),
}))
}
export const POST = withRouteHandler(async (request: NextRequest) => {
const requestId = generateRequestId()
try {
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
logger.warn(`[${requestId}] Unauthorized Textract analyze-id attempt`, {
error: authResult.error || 'Missing userId',
})
return NextResponse.json(
{ success: false, error: authResult.error || 'Unauthorized' },
{ status: 401 }
)
}
const userId = authResult.userId
const parsed = await parseRequest(
textractAnalyzeIdContract,
request,
{},
{
validationErrorResponse: (error) => {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.issues })
return NextResponse.json(
{
success: false,
error: getValidationErrorMessage(error, 'Invalid request data'),
details: error.issues,
},
{ status: 400 }
)
},
}
)
if (!parsed.success) return parsed.response
const validatedData = parsed.data.body
logger.info(`[${requestId}] Textract analyze-id request`, {
hasFile: Boolean(validatedData.file),
hasBackFile: Boolean(validatedData.fileBack || validatedData.filePathBack),
userId,
})
const front = await resolveDocumentInput(
{ file: validatedData.file, filePath: validatedData.filePath },
userId,
requestId,
logger
)
if (!front.ok) return front.response
const documentPages = [{ Bytes: front.document.bytes }]
let isPdf = front.document.isPdf
if (validatedData.fileBack || validatedData.filePathBack) {
const back = await resolveDocumentInput(
{ file: validatedData.fileBack, filePath: validatedData.filePathBack },
userId,
requestId,
logger
)
if (!back.ok) return back.response
documentPages.push({ Bytes: back.document.bytes })
isPdf = isPdf || back.document.isPdf
}
const client = new TextractClient({
region: validatedData.region,
credentials: {
accessKeyId: validatedData.accessKeyId,
secretAccessKey: validatedData.secretAccessKey,
},
})
let result: {
AnalyzeIDModelVersion?: string
DocumentMetadata?: { Pages?: number }
IdentityDocuments?: IdentityDocument[]
}
try {
result = await client.send(new AnalyzeIDCommand({ DocumentPages: documentPages }))
} catch (error) {
throw mapTextractSdkError(error, isPdf, { hasAsyncMode: false })
}
logger.info(`[${requestId}] Textract analyze-id successful`, {
pageCount: result.DocumentMetadata?.Pages ?? 0,
documentCount: result.IdentityDocuments?.length ?? 0,
})
return NextResponse.json({
success: true,
output: {
identityDocuments: normalizeIdentityDocuments(result.IdentityDocuments ?? []),
documentMetadata: { pages: result.DocumentMetadata?.Pages ?? 0 },
modelVersion: result.AnalyzeIDModelVersion,
},
})
} catch (error) {
return textractErrorResponse(error, requestId, logger)
}
})
@@ -0,0 +1,220 @@
import {
AnalyzeDocumentCommand,
DetectDocumentTextCommand,
type FeatureType,
GetDocumentAnalysisCommand,
GetDocumentTextDetectionCommand,
StartDocumentAnalysisCommand,
StartDocumentTextDetectionCommand,
TextractClient,
} from '@aws-sdk/client-textract'
import { createLogger } from '@sim/logger'
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { textractParseContract } from '@/lib/api/contracts/tools/media/document-parse'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
mapTextractSdkError,
parseS3Uri,
pollTextractJob,
resolveDocumentInput,
textractErrorResponse,
} from '@/app/api/tools/textract/shared'
export const dynamic = 'force-dynamic'
/**
* Mirrors the maximum plan execution timeout (enterprise async, 90 minutes) used by
* `getMaxExecutionTimeout()` for the job polling loop below. Next.js requires a static
* literal for `maxDuration`, so this value must be kept in sync with that source.
*/
export const maxDuration = 5400
const logger = createLogger('TextractParseAPI')
/** Response shape shared by AnalyzeDocument/DetectDocumentText and their async Get* counterparts. */
interface TextractDocumentResult {
JobStatus?: string
StatusMessage?: string
NextToken?: string
Blocks?: unknown[]
DocumentMetadata?: { Pages?: number }
AnalyzeDocumentModelVersion?: string
DetectDocumentTextModelVersion?: string
}
export const POST = withRouteHandler(async (request: NextRequest) => {
const requestId = generateRequestId()
try {
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
logger.warn(`[${requestId}] Unauthorized Textract parse attempt`, {
error: authResult.error || 'Missing userId',
})
return NextResponse.json(
{ success: false, error: authResult.error || 'Unauthorized' },
{ status: 401 }
)
}
const userId = authResult.userId
const parsed = await parseRequest(
textractParseContract,
request,
{},
{
validationErrorResponse: (error) => {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.issues })
return NextResponse.json(
{
success: false,
error: getValidationErrorMessage(error, 'Invalid request data'),
details: error.issues,
},
{ status: 400 }
)
},
}
)
if (!parsed.success) return parsed.response
const validatedData = parsed.data.body
const processingMode = validatedData.processingMode || 'sync'
const featureTypes = (validatedData.featureTypes ?? []) as FeatureType[]
const useAnalyzeDocument = featureTypes.length > 0
const queriesConfig =
validatedData.queries && validatedData.queries.length > 0 && featureTypes.includes('QUERIES')
? {
Queries: validatedData.queries.map((q) => ({
Text: q.Text,
Alias: q.Alias,
Pages: q.Pages,
})),
}
: undefined
logger.info(`[${requestId}] Textract parse request`, {
processingMode,
hasFile: Boolean(validatedData.file),
hasS3Uri: Boolean(validatedData.s3Uri),
featureTypes,
userId,
})
const client = new TextractClient({
region: validatedData.region,
credentials: {
accessKeyId: validatedData.accessKeyId,
secretAccessKey: validatedData.secretAccessKey,
},
})
if (processingMode === 'async') {
if (!validatedData.s3Uri) {
return NextResponse.json(
{
success: false,
error: 'S3 URI is required for multi-page processing (s3://bucket/key)',
},
{ status: 400 }
)
}
const { bucket, key } = parseS3Uri(validatedData.s3Uri)
logger.info(`[${requestId}] Starting async Textract job`, { s3Bucket: bucket, s3Key: key })
const { JobId: jobId } = useAnalyzeDocument
? await client.send(
new StartDocumentAnalysisCommand({
DocumentLocation: { S3Object: { Bucket: bucket, Name: key } },
FeatureTypes: featureTypes,
QueriesConfig: queriesConfig,
})
)
: await client.send(
new StartDocumentTextDetectionCommand({
DocumentLocation: { S3Object: { Bucket: bucket, Name: key } },
})
)
if (!jobId) {
throw new Error('Failed to start Textract job: No JobId returned')
}
logger.info(`[${requestId}] Async job started`, { jobId })
const result = await pollTextractJob<TextractDocumentResult>(
requestId,
logger,
async (nextToken) =>
useAnalyzeDocument
? await client.send(
new GetDocumentAnalysisCommand({ JobId: jobId, NextToken: nextToken })
)
: await client.send(
new GetDocumentTextDetectionCommand({ JobId: jobId, NextToken: nextToken })
),
(accumulated, page) => ({
...accumulated,
...page,
Blocks: [...(accumulated.Blocks ?? []), ...(page.Blocks ?? [])],
})
)
logger.info(`[${requestId}] Textract async parse successful`, {
pageCount: result.DocumentMetadata?.Pages ?? 0,
blockCount: result.Blocks?.length ?? 0,
})
return NextResponse.json({
success: true,
output: {
blocks: result.Blocks ?? [],
documentMetadata: { pages: result.DocumentMetadata?.Pages ?? 0 },
modelVersion: result.AnalyzeDocumentModelVersion ?? result.DetectDocumentTextModelVersion,
},
})
}
const resolved = await resolveDocumentInput(
{ file: validatedData.file, filePath: validatedData.filePath },
userId,
requestId,
logger
)
if (!resolved.ok) return resolved.response
const { bytes, isPdf } = resolved.document
let result: TextractDocumentResult
try {
result = useAnalyzeDocument
? await client.send(
new AnalyzeDocumentCommand({
Document: { Bytes: bytes },
FeatureTypes: featureTypes,
QueriesConfig: queriesConfig,
})
)
: await client.send(new DetectDocumentTextCommand({ Document: { Bytes: bytes } }))
} catch (error) {
throw mapTextractSdkError(error, isPdf)
}
logger.info(`[${requestId}] Textract parse successful`, {
pageCount: result.DocumentMetadata?.Pages ?? 0,
blockCount: result.Blocks?.length ?? 0,
})
return NextResponse.json({
success: true,
output: {
blocks: result.Blocks ?? [],
documentMetadata: { pages: result.DocumentMetadata?.Pages ?? 0 },
modelVersion: result.AnalyzeDocumentModelVersion ?? result.DetectDocumentTextModelVersion,
},
})
} catch (error) {
return textractErrorResponse(error, requestId, logger)
}
})
@@ -0,0 +1,165 @@
/**
* @vitest-environment node
*/
import { createLogger } from '@sim/logger'
import { describe, expect, it } from 'vitest'
import {
mapTextractSdkError,
parseS3Uri,
pollTextractJob,
TextractRouteError,
} from '@/app/api/tools/textract/shared'
const logger = createLogger('TextractSharedTest')
describe('parseS3Uri', () => {
it('parses a valid s3 URI', () => {
expect(parseS3Uri('s3://my-bucket/path/to/doc.pdf')).toEqual({
bucket: 'my-bucket',
key: 'path/to/doc.pdf',
})
})
it('rejects a malformed URI', () => {
expect(() => parseS3Uri('not-an-s3-uri')).toThrow(TextractRouteError)
})
it('rejects path traversal in the key', () => {
expect(() => parseS3Uri('s3://my-bucket/../secrets.pdf')).toThrow('path traversal')
})
})
describe('mapTextractSdkError', () => {
it('gives a friendly hint for unsupported PDFs in single-page mode', () => {
const mapped = mapTextractSdkError(
{ name: 'UnsupportedDocumentException', message: 'Unsupported document' },
true
)
expect(mapped.status).toBe(400)
expect(mapped.message).toContain('Multi-Page (PDF, TIFF via S3)')
})
it('omits the multi-page hint for operations without an async mode', () => {
const mapped = mapTextractSdkError(
{ name: 'UnsupportedDocumentException', message: 'Unsupported document' },
true,
{ hasAsyncMode: false }
)
expect(mapped.message).not.toContain('Multi-Page')
expect(mapped.message).toContain('Only JPEG, PNG, and single-page PDF files are supported')
})
it('does not rewrite the message for non-PDF unsupported documents', () => {
const mapped = mapTextractSdkError(
{ name: 'UnsupportedDocumentException', message: 'Unsupported document' },
false
)
expect(mapped.message).toBe('Unsupported document')
})
it('uses the SDK http status when under 500', () => {
const mapped = mapTextractSdkError(
{
name: 'InvalidParameterException',
message: 'Bad param',
$metadata: { httpStatusCode: 400 },
},
false
)
expect(mapped.status).toBe(400)
expect(mapped.message).toBe('Bad param')
})
it('passes through a 5xx SDK status so tool-execution retry logic still fires', () => {
const mapped = mapTextractSdkError(
{ message: 'Internal failure', $metadata: { httpStatusCode: 500 } },
false
)
expect(mapped.status).toBe(500)
})
it('defaults to 500 when the SDK gives no http status, since that implies a server-side failure', () => {
const mapped = mapTextractSdkError({ message: 'Unknown failure' }, false)
expect(mapped.status).toBe(500)
})
})
describe('pollTextractJob', () => {
it('returns immediately on SUCCEEDED with no NextToken', async () => {
const result = await pollTextractJob(
'req-1',
logger,
async () => ({ JobStatus: 'SUCCEEDED', Blocks: [{ Id: '1' }] }),
(accumulated, page) => ({
...page,
Blocks: [...(accumulated.Blocks ?? []), ...(page.Blocks ?? [])],
})
)
expect(result.JobStatus).toBe('SUCCEEDED')
expect(result.Blocks).toHaveLength(1)
})
it('follows NextToken pagination and merges pages', async () => {
let calls = 0
const result = await pollTextractJob(
'req-2',
logger,
async (nextToken) => {
calls += 1
if (!nextToken) return { JobStatus: 'SUCCEEDED', Blocks: [{ Id: '1' }], NextToken: 'next' }
return { JobStatus: 'SUCCEEDED', Blocks: [{ Id: '2' }] }
},
(accumulated, page) => ({
...accumulated,
...page,
Blocks: [...(accumulated.Blocks ?? []), ...(page.Blocks ?? [])],
})
)
expect(calls).toBe(2)
expect(result.Blocks).toHaveLength(2)
})
it('preserves fields the first page has but a later page omits (e.g. DocumentMetadata)', async () => {
const result = await pollTextractJob<{
JobStatus?: string
NextToken?: string
Blocks?: unknown[]
DocumentMetadata?: { Pages?: number }
}>(
'req-4',
logger,
async (nextToken) => {
if (!nextToken) {
return {
JobStatus: 'SUCCEEDED',
Blocks: [{ Id: '1' }],
DocumentMetadata: { Pages: 3 },
NextToken: 'next',
}
}
return { JobStatus: 'SUCCEEDED', Blocks: [{ Id: '2' }] }
},
(accumulated, page) => ({
...accumulated,
...page,
Blocks: [...(accumulated.Blocks ?? []), ...(page.Blocks ?? [])],
})
)
expect(result.Blocks).toHaveLength(2)
expect(result.DocumentMetadata).toEqual({ Pages: 3 })
})
it('throws a TextractRouteError when the job fails', async () => {
await expect(
pollTextractJob(
'req-3',
logger,
async () => ({ JobStatus: 'FAILED', StatusMessage: 'boom' }),
(accumulated) => accumulated
)
).rejects.toThrow('Textract job failed: boom')
})
})
+305
View File
@@ -0,0 +1,305 @@
import type { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { sleep } from '@sim/utils/helpers'
import { NextResponse } from 'next/server'
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
import { validateS3BucketName } from '@/lib/core/security/input-validation'
import {
secureFetchWithPinnedIP,
validateUrlWithDNS,
} from '@/lib/core/security/input-validation.server'
import type { RawFileInput } from '@/lib/uploads/utils/file-utils'
import { isInternalFileUrl, processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils'
import {
downloadServableFileFromStorage,
resolveInternalFileUrl,
} from '@/lib/uploads/utils/file-utils.server'
import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response'
import { assertToolFileAccess } from '@/app/api/files/authorization'
type RouteLogger = ReturnType<typeof createLogger>
/** Thrown by AWS SDK call sites so route handlers can map failures to the right HTTP status. */
export class TextractRouteError extends Error {
status: number
constructor(message: string, status = 500) {
super(message)
this.name = 'TextractRouteError'
this.status = status
}
}
export function textractErrorResponse(
error: unknown,
requestId: string,
logger: RouteLogger
): NextResponse {
const notReady = docNotReadyResponse(error)
if (notReady) return notReady
logger.error(`[${requestId}] Error in Textract request:`, error)
const status = error instanceof TextractRouteError ? error.status : 500
return NextResponse.json(
{ success: false, error: getErrorMessage(error, 'Internal server error') },
{ status }
)
}
/**
* Maps an AWS SDK TextractClient rejection to a client-facing error, with a friendly hint for the
* common "PDF used in single-page mode" mistake. The real AWS HTTP status (including 5xx) is
* passed through so the tool-execution layer's retry logic can still treat throttling/internal
* errors as retryable, matching the pre-migration hand-rolled-signing behavior.
*/
export function mapTextractSdkError(
error: unknown,
isPdf: boolean,
options?: { hasAsyncMode?: boolean }
): TextractRouteError {
const err = error as {
name?: string
message?: string
$metadata?: { httpStatusCode?: number }
}
const hasAsyncMode = options?.hasAsyncMode ?? true
const isUnsupportedFormat =
err.name === 'UnsupportedDocumentException' ||
Boolean(err.message?.toLowerCase().includes('unsupported document'))
if (isUnsupportedFormat && isPdf) {
const hint = hasAsyncMode
? ' If this is a multi-page PDF, please use "Multi-Page (PDF, TIFF via S3)" mode instead, which requires uploading your document to S3 first. Single Page mode only supports JPEG, PNG, and single-page PDF files.'
: ' Only JPEG, PNG, and single-page PDF files are supported.'
return new TextractRouteError(`This document format is not supported.${hint}`, 400)
}
const status = err.$metadata?.httpStatusCode ?? 500
return new TextractRouteError(err.message || 'Textract API error', status)
}
export interface ResolvedDocument {
bytes: Buffer
contentType: string
isPdf: boolean
}
export type ResolveDocumentResult =
| { ok: true; document: ResolvedDocument }
| { ok: false; response: NextResponse }
/** Passes through the document host's real HTTP status on failure, so tool-execution retry logic can still treat a transient 5xx as retryable. */
async function fetchDocumentBytes(url: string): Promise<{ bytes: Buffer; contentType: string }> {
const urlValidation = await validateUrlWithDNS(url, 'Document URL')
if (!urlValidation.isValid) {
throw new TextractRouteError(urlValidation.error || 'Invalid document URL', 400)
}
const response = await secureFetchWithPinnedIP(url, urlValidation.resolvedIP!, {
method: 'GET',
})
if (!response.ok) {
await response.text().catch(() => {})
throw new TextractRouteError(
`Failed to fetch document: ${response.statusText}`,
response.status
)
}
const arrayBuffer = await response.arrayBuffer()
const contentType = response.headers.get('content-type') || 'application/octet-stream'
return { bytes: Buffer.from(arrayBuffer), contentType }
}
/** Resolves a document input (uploaded file reference or URL) to raw bytes for the Textract Document.Bytes field. */
export async function resolveDocumentInput(
input: { file?: RawFileInput; filePath?: string },
userId: string,
requestId: string,
logger: RouteLogger
): Promise<ResolveDocumentResult> {
if (input.file) {
let userFile: ReturnType<typeof processSingleFileToUserFile>
try {
userFile = processSingleFileToUserFile(input.file, requestId, logger)
} catch (error) {
return {
ok: false,
response: NextResponse.json(
{ success: false, error: getErrorMessage(error, 'Failed to process file') },
{ status: 400 }
),
}
}
const denied = await assertToolFileAccess(userFile.key, userId, requestId, logger)
if (denied) return { ok: false, response: denied }
const { buffer, contentType } = await downloadServableFileFromStorage(
userFile,
requestId,
logger
)
const resolvedContentType = contentType || userFile.type || 'application/octet-stream'
return {
ok: true,
document: {
bytes: buffer,
contentType: resolvedContentType,
isPdf:
resolvedContentType.includes('pdf') ||
Boolean(userFile.name?.toLowerCase().endsWith('.pdf')),
},
}
}
if (input.filePath) {
let fileUrl = input.filePath
const isInternalFilePath = isInternalFileUrl(fileUrl)
if (isInternalFilePath) {
const resolution = await resolveInternalFileUrl(fileUrl, userId, requestId, logger)
if (resolution.error) {
return {
ok: false,
response: NextResponse.json(
{ success: false, error: resolution.error.message },
{ status: resolution.error.status }
),
}
}
fileUrl = resolution.fileUrl || fileUrl
} else if (fileUrl.startsWith('/')) {
logger.warn(`[${requestId}] Invalid internal path`, {
userId,
path: fileUrl.substring(0, 50),
})
return {
ok: false,
response: NextResponse.json(
{
success: false,
error: 'Invalid file path. Only uploaded files are supported for internal paths.',
},
{ status: 400 }
),
}
} else {
const urlValidation = await validateUrlWithDNS(fileUrl, 'Document URL')
if (!urlValidation.isValid) {
logger.warn(`[${requestId}] SSRF attempt blocked`, {
userId,
url: fileUrl.substring(0, 100),
error: urlValidation.error,
})
return {
ok: false,
response: NextResponse.json(
{ success: false, error: urlValidation.error },
{ status: 400 }
),
}
}
}
const fetched = await fetchDocumentBytes(fileUrl)
return {
ok: true,
document: {
bytes: fetched.bytes,
contentType: fetched.contentType,
isPdf: fetched.contentType.includes('pdf') || fileUrl.toLowerCase().endsWith('.pdf'),
},
}
}
return {
ok: false,
response: NextResponse.json(
{ success: false, error: 'Document input is required' },
{ status: 400 }
),
}
}
export function parseS3Uri(s3Uri: string): { bucket: string; key: string } {
const match = s3Uri.match(/^s3:\/\/([^/]+)\/(.+)$/)
if (!match) {
throw new TextractRouteError(
`Invalid S3 URI format: ${s3Uri}. Expected format: s3://bucket-name/path/to/object`,
400
)
}
const bucket = match[1]
const key = match[2]
const bucketValidation = validateS3BucketName(bucket, 'S3 bucket name')
if (!bucketValidation.isValid) {
throw new TextractRouteError(bucketValidation.error || 'Invalid S3 bucket name', 400)
}
if (key.includes('..') || key.startsWith('/')) {
throw new TextractRouteError('S3 key contains invalid path traversal sequences', 400)
}
return { bucket, key }
}
interface PollableJobResult {
JobStatus?: string
StatusMessage?: string
NextToken?: string
}
/** Polls a started async Textract job (StartDocumentAnalysis/StartDocumentTextDetection/StartExpenseAnalysis) until it completes, following NextToken pagination on success. */
export async function pollTextractJob<TResult extends PollableJobResult>(
requestId: string,
logger: RouteLogger,
getPage: (nextToken?: string) => Promise<TResult>,
mergePage: (accumulated: TResult, page: TResult) => TResult
): Promise<TResult> {
const pollIntervalMs = 5000
const maxPollTimeMs = getMaxExecutionTimeout()
const maxAttempts = Math.ceil(maxPollTimeMs / pollIntervalMs)
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const result = await getPage()
const jobStatus = result.JobStatus
if (jobStatus === 'SUCCEEDED' || jobStatus === 'PARTIAL_SUCCESS') {
if (jobStatus === 'PARTIAL_SUCCESS') {
logger.warn(`[${requestId}] Job completed with partial success: ${result.StatusMessage}`)
} else {
logger.info(`[${requestId}] Async job completed successfully after ${attempt + 1} polls`)
}
let merged = result
let nextToken = result.NextToken
while (nextToken) {
const page = await getPage(nextToken)
merged = mergePage(merged, page)
nextToken = page.NextToken
}
return merged
}
if (jobStatus === 'FAILED') {
throw new TextractRouteError(
`Textract job failed: ${result.StatusMessage || 'Unknown error'}`,
502
)
}
logger.info(`[${requestId}] Job status: ${jobStatus}, attempt ${attempt + 1}/${maxAttempts}`)
await sleep(pollIntervalMs)
}
throw new TextractRouteError(
`Timeout waiting for Textract job to complete (max ${maxPollTimeMs / 1000} seconds)`,
504
)
}