Skip to content

Commit d842395

Browse files
committed
refactor(tools): route grafana/agiloft egress server-side, drop SSRF browser shim
Move the server-only SSRF-pinned fetch out of the grafana (update_dashboard, update_alert_rule) and agiloft (11 record/search tools) definitions and into internal API routes, the same pattern the rest of the server-side tools (and agiloft's own attach/retrieve) already use. The tool definitions are now purely declarative (request → internal route), so they no longer import `input-validation.server` and the tools registry is fully client-safe. With connectors (meta split) and these tools no longer reaching server-only code from the client bundle, the browser no longer pulls in `dns`/`net`/`tls`: - Add `import 'server-only'` to `input-validation.server.ts` so any future client import fails loudly at build time instead of silently bloating the bundle. - Remove the `turbopack.resolveAlias` browser stub and delete `empty-node-fallback.browser.ts` — the root cause is fixed, the shim is gone. Behavior is unchanged: each route runs the exact merge/validation/fetch logic the tool ran before (every header, param branch, JSON-parse guard, error string, and SSRF pinning preserved); only the location of execution moved from the client- bundled definition to a server route.
1 parent c69d562 commit d842395

33 files changed

Lines changed: 2245 additions & 860 deletions

File tree

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { createLogger } from '@sim/logger'
2+
import { toError } from '@sim/utils/errors'
3+
import { type NextRequest, NextResponse } from 'next/server'
4+
import { agiloftAttachmentInfoContract } from '@/lib/api/contracts/tools/agiloft'
5+
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
6+
import { checkInternalAuth } from '@/lib/auth/hybrid'
7+
import { generateRequestId } from '@/lib/core/utils/request'
8+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
9+
import type { AgiloftAttachmentInfoResponse } from '@/tools/agiloft/types'
10+
import { buildAttachmentInfoUrl } from '@/tools/agiloft/utils'
11+
import { executeAgiloftRequest } from '@/tools/agiloft/utils.server'
12+
13+
export const dynamic = 'force-dynamic'
14+
15+
const logger = createLogger('AgiloftAttachmentInfoAPI')
16+
17+
export const POST = withRouteHandler(async (request: NextRequest) => {
18+
const requestId = generateRequestId()
19+
20+
try {
21+
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
22+
23+
if (!authResult.success || !authResult.userId) {
24+
logger.warn(
25+
`[${requestId}] Unauthorized Agiloft attachment_info attempt: ${authResult.error}`
26+
)
27+
return NextResponse.json(
28+
{ success: false, error: authResult.error || 'Authentication required' },
29+
{ status: 401 }
30+
)
31+
}
32+
33+
const parsed = await parseRequest(
34+
agiloftAttachmentInfoContract,
35+
request,
36+
{},
37+
{
38+
validationErrorResponse: (error) => {
39+
logger.warn(`[${requestId}] Invalid request data`, { errors: error.issues })
40+
return NextResponse.json(
41+
{
42+
success: false,
43+
error: getValidationErrorMessage(error, 'Invalid request data'),
44+
details: error.issues,
45+
},
46+
{ status: 400 }
47+
)
48+
},
49+
}
50+
)
51+
if (!parsed.success) return parsed.response
52+
const params = parsed.data.body
53+
54+
const result = await executeAgiloftRequest<AgiloftAttachmentInfoResponse>(
55+
params,
56+
(base) => ({
57+
url: buildAttachmentInfoUrl(base, params),
58+
method: 'GET',
59+
}),
60+
async (response) => {
61+
if (!response.ok) {
62+
const errorText = await response.text()
63+
return {
64+
success: false,
65+
output: { attachments: [], totalCount: 0 },
66+
error: `Agiloft error: ${response.status} - ${errorText}`,
67+
}
68+
}
69+
70+
const data = (await response.json()) as Record<string, unknown>
71+
const result = (data.result ?? data) as Record<string, unknown>
72+
73+
const attachments: Array<{ position: number; name: string; size: number }> = []
74+
75+
if (Array.isArray(result)) {
76+
for (let i = 0; i < result.length; i++) {
77+
const item = result[i] as Record<string, unknown>
78+
attachments.push({
79+
position: (item.filePosition as number) ?? (item.position as number) ?? i,
80+
name:
81+
(item.fileName as string) ??
82+
(item.name as string) ??
83+
(item.filename as string) ??
84+
'',
85+
size: (item.size as number) ?? (item.fileSize as number) ?? 0,
86+
})
87+
}
88+
}
89+
90+
return {
91+
success: data.success !== false,
92+
output: {
93+
attachments,
94+
totalCount: attachments.length,
95+
},
96+
}
97+
}
98+
)
99+
100+
return NextResponse.json(result)
101+
} catch (error) {
102+
logger.error(`[${requestId}] Error getting Agiloft attachment info:`, error)
103+
104+
return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 })
105+
}
106+
})
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { createLogger } from '@sim/logger'
2+
import { toError } from '@sim/utils/errors'
3+
import { type NextRequest, NextResponse } from 'next/server'
4+
import { agiloftCreateRecordContract } from '@/lib/api/contracts/tools/agiloft'
5+
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
6+
import { checkInternalAuth } from '@/lib/auth/hybrid'
7+
import { generateRequestId } from '@/lib/core/utils/request'
8+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
9+
import type { AgiloftRecordResponse } from '@/tools/agiloft/types'
10+
import { buildCreateRecordUrl } from '@/tools/agiloft/utils'
11+
import { executeAgiloftRequest } from '@/tools/agiloft/utils.server'
12+
13+
export const dynamic = 'force-dynamic'
14+
15+
const logger = createLogger('AgiloftCreateRecordAPI')
16+
17+
export const POST = withRouteHandler(async (request: NextRequest) => {
18+
const requestId = generateRequestId()
19+
20+
try {
21+
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
22+
23+
if (!authResult.success || !authResult.userId) {
24+
logger.warn(`[${requestId}] Unauthorized Agiloft create_record attempt: ${authResult.error}`)
25+
return NextResponse.json(
26+
{ success: false, error: authResult.error || 'Authentication required' },
27+
{ status: 401 }
28+
)
29+
}
30+
31+
const parsed = await parseRequest(
32+
agiloftCreateRecordContract,
33+
request,
34+
{},
35+
{
36+
validationErrorResponse: (error) => {
37+
logger.warn(`[${requestId}] Invalid request data`, { errors: error.issues })
38+
return NextResponse.json(
39+
{
40+
success: false,
41+
error: getValidationErrorMessage(error, 'Invalid request data'),
42+
details: error.issues,
43+
},
44+
{ status: 400 }
45+
)
46+
},
47+
}
48+
)
49+
if (!parsed.success) return parsed.response
50+
const params = parsed.data.body
51+
52+
let body: string
53+
try {
54+
body = JSON.stringify(JSON.parse(params.data))
55+
} catch {
56+
return NextResponse.json({
57+
success: false,
58+
output: { id: null, fields: {} },
59+
error: 'Invalid JSON in data parameter',
60+
})
61+
}
62+
63+
const result = await executeAgiloftRequest<AgiloftRecordResponse>(
64+
params,
65+
(base) => ({
66+
url: buildCreateRecordUrl(base, params),
67+
method: 'POST',
68+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
69+
body,
70+
}),
71+
async (response) => {
72+
if (!response.ok) {
73+
const errorText = await response.text()
74+
return {
75+
success: false,
76+
output: { id: null, fields: {} },
77+
error: `Agiloft error: ${response.status} - ${errorText}`,
78+
}
79+
}
80+
81+
const data = (await response.json()) as Record<string, unknown>
82+
const result = (data.result ?? data) as Record<string, unknown>
83+
const id = result.id ?? result.ID ?? data.id ?? data.ID ?? null
84+
85+
return {
86+
success: data.success !== false,
87+
output: {
88+
id: id != null ? String(id) : null,
89+
fields: result ?? {},
90+
},
91+
}
92+
}
93+
)
94+
95+
return NextResponse.json(result)
96+
} catch (error) {
97+
logger.error(`[${requestId}] Error creating Agiloft record:`, error)
98+
99+
return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 })
100+
}
101+
})
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { createLogger } from '@sim/logger'
2+
import { toError } from '@sim/utils/errors'
3+
import { type NextRequest, NextResponse } from 'next/server'
4+
import { agiloftDeleteRecordContract } from '@/lib/api/contracts/tools/agiloft'
5+
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
6+
import { checkInternalAuth } from '@/lib/auth/hybrid'
7+
import { generateRequestId } from '@/lib/core/utils/request'
8+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
9+
import type { AgiloftDeleteResponse } from '@/tools/agiloft/types'
10+
import { buildDeleteRecordUrl } from '@/tools/agiloft/utils'
11+
import { executeAgiloftRequest } from '@/tools/agiloft/utils.server'
12+
13+
export const dynamic = 'force-dynamic'
14+
15+
const logger = createLogger('AgiloftDeleteRecordAPI')
16+
17+
export const POST = withRouteHandler(async (request: NextRequest) => {
18+
const requestId = generateRequestId()
19+
20+
try {
21+
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
22+
23+
if (!authResult.success || !authResult.userId) {
24+
logger.warn(`[${requestId}] Unauthorized Agiloft delete_record attempt: ${authResult.error}`)
25+
return NextResponse.json(
26+
{ success: false, error: authResult.error || 'Authentication required' },
27+
{ status: 401 }
28+
)
29+
}
30+
31+
const parsed = await parseRequest(
32+
agiloftDeleteRecordContract,
33+
request,
34+
{},
35+
{
36+
validationErrorResponse: (error) => {
37+
logger.warn(`[${requestId}] Invalid request data`, { errors: error.issues })
38+
return NextResponse.json(
39+
{
40+
success: false,
41+
error: getValidationErrorMessage(error, 'Invalid request data'),
42+
details: error.issues,
43+
},
44+
{ status: 400 }
45+
)
46+
},
47+
}
48+
)
49+
if (!parsed.success) return parsed.response
50+
const params = parsed.data.body
51+
52+
const result = await executeAgiloftRequest<AgiloftDeleteResponse>(
53+
params,
54+
(base) => ({
55+
url: buildDeleteRecordUrl(base, params),
56+
method: 'DELETE',
57+
headers: { Accept: 'application/json' },
58+
}),
59+
async (response) => {
60+
if (!response.ok) {
61+
const errorText = await response.text()
62+
return {
63+
success: false,
64+
output: { id: params.recordId?.trim() ?? '', deleted: false },
65+
error: `Agiloft error: ${response.status} - ${errorText}`,
66+
}
67+
}
68+
69+
return {
70+
success: true,
71+
output: {
72+
id: params.recordId?.trim() ?? '',
73+
deleted: true,
74+
},
75+
}
76+
}
77+
)
78+
79+
return NextResponse.json(result)
80+
} catch (error) {
81+
logger.error(`[${requestId}] Error deleting Agiloft record:`, error)
82+
83+
return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 })
84+
}
85+
})

0 commit comments

Comments
 (0)