Skip to content

Commit 3143a15

Browse files
authored
feat(uptimerobot): add UptimeRobot v3 integration (#5229)
* feat(uptimerobot): add UptimeRobot v3 integration - 24 tools across monitors, incidents, maintenance windows, alert contacts, public status pages, and account (UptimeRobot v3 REST API, Bearer auth) - Block with operation-scoped subBlocks, status-page logo/icon file uploads via internal multipart routes, and BlockMeta templates + skills - Registered tools/block, added icon, generated docs - Updated add-integration/add-block/validate-integration docs links to /integrations * fix(uptimerobot): address review — heartbeat URL, file/JSON edge cases - Block: URL is not required for HEARTBEAT monitors (no URL) - buildMonitorBody: throw on malformed assignedAlertContacts/customHttpHeaders JSON instead of silently dropping the field - PSP route: error (400) when a supplied logo/icon cannot be resolved to a stored file instead of silently omitting the image - PSP route: guard success-path JSON parsing; return a controlled 502 on a non-JSON provider response instead of an uncaught 500 * fix(uptimerobot): spec-conformance audit fixes - pause/start monitor: send Content-Type: application/json (v3 spec requires it on these POSTs even with an empty body) - update maintenance window: drop autoAddMonitors (not in UpdateMaintenanceWindowDto); gate the block field to create only * fix(uptimerobot): rename monitor timeout param to avoid reserved name The tool runner treats a top-level `timeout` param as the outbound HTTP-client timeout (ms), so a monitor check-timeout of e.g. 30s would abort the API call in 30ms. Rename the input to `checkTimeout` (block subBlock, tool params, inputs, numeric coercion) and map it to the API body's `timeout` key in buildMonitorBody. * fix(uptimerobot): reject empty/non-object PSP responses A successful PSP create/update must return the PspDto object; an empty or non-object body now returns a controlled 502 instead of mapping a phantom status page (id: 0, empty name, null images) back to the workflow. * fix(uptimerobot): validate core PSP fields before mapping Reject successful PSP responses that lack a positive numeric id and non-empty friendlyName (a {} or metadata envelope) with a controlled 502, instead of mapping a phantom status page.
1 parent 35acc42 commit 3143a15

44 files changed

Lines changed: 5474 additions & 6 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude/commands/add-block.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export const {ServiceName}Block: BlockConfig = {
2727
name: '{Service Name}', // Human readable
2828
description: 'Brief description', // One sentence
2929
longDescription: 'Detailed description for docs',
30-
docsLink: 'https://docs.sim.ai/tools/{service}',
30+
docsLink: 'https://docs.sim.ai/integrations/{service}',
3131
category: 'tools', // 'tools' | 'blocks' | 'triggers'
3232
integrationType: IntegrationType.X, // Primary category (see IntegrationType enum)
3333
tags: ['oauth', 'api'], // Cross-cutting tags (see IntegrationTag type)
@@ -626,7 +626,7 @@ export const ServiceBlock: BlockConfig = {
626626
name: 'Service',
627627
description: 'Integrate with Service API',
628628
longDescription: 'Full description for documentation...',
629-
docsLink: 'https://docs.sim.ai/tools/service',
629+
docsLink: 'https://docs.sim.ai/integrations/service',
630630
category: 'tools',
631631
integrationType: IntegrationType.DeveloperTools,
632632
tags: ['oauth', 'api'],

.claude/commands/add-integration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ export const {Service}Block: BlockConfig = {
121121
name: '{Service}',
122122
description: '...',
123123
longDescription: '...',
124-
docsLink: 'https://docs.sim.ai/tools/{service}',
124+
docsLink: 'https://docs.sim.ai/integrations/{service}',
125125
category: 'tools',
126126
integrationType: IntegrationType.X, // Primary category (see IntegrationType enum)
127127
tags: ['oauth', 'api'], // Cross-cutting tags (see IntegrationTag type)

.claude/commands/validate-integration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ For **each tool** in `tools.access`:
185185
- [ ] `name` is human-readable (e.g., `'X'`, `'Cloudflare'`)
186186
- [ ] `description` is a concise one-liner
187187
- [ ] `longDescription` provides detail for docs
188-
- [ ] `docsLink` points to `'https://docs.sim.ai/tools/{service}'`
188+
- [ ] `docsLink` points to `'https://docs.sim.ai/integrations/{service}'`
189189
- [ ] `category` is `'tools'`
190190
- [ ] `bgColor` uses the service's brand color hex
191191
- [ ] `icon` references the correct icon component from `@/components/icons`

apps/docs/components/icons.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7727,6 +7727,17 @@ export function UpstashIcon(props: SVGProps<SVGSVGElement>) {
77277727
)
77287728
}
77297729

7730+
export function UptimeRobotIcon(props: SVGProps<SVGSVGElement>) {
7731+
return (
7732+
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 298 298' width='24' height='24'>
7733+
<g fill='#3BD771' transform='translate(.9 .9)'>
7734+
<circle cx='148.1' cy='148.1' r='148.1' opacity='.3' />
7735+
<circle cx='148.1' cy='148.1' r='98.9' />
7736+
</g>
7737+
</svg>
7738+
)
7739+
}
7740+
77307741
export function RevenueCatIcon(props: SVGProps<SVGSVGElement>) {
77317742
return (
77327743
<svg

apps/docs/components/ui/icon-mapping.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,7 @@ import {
217217
TwilioIcon,
218218
TypeformIcon,
219219
UpstashIcon,
220+
UptimeRobotIcon,
220221
VantaIcon,
221222
VercelIcon,
222223
VideoIcon,
@@ -477,6 +478,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
477478
twilio_voice: TwilioIcon,
478479
typeform: TypeformIcon,
479480
upstash: UpstashIcon,
481+
uptimerobot: UptimeRobotIcon,
480482
vanta: VantaIcon,
481483
vercel: VercelIcon,
482484
video_generator: VideoIcon,

apps/docs/content/docs/en/integrations/meta.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,7 @@
219219
"twilio_voice",
220220
"typeform",
221221
"upstash",
222+
"uptimerobot",
222223
"vanta",
223224
"vercel",
224225
"wealthbox",

apps/docs/content/docs/en/integrations/uptimerobot.mdx

Lines changed: 915 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { createLogger } from '@sim/logger'
2+
import { getErrorMessage } from '@sim/utils/errors'
3+
import { type NextRequest, NextResponse } from 'next/server'
4+
import { uptimeRobotCreatePspContract } from '@/lib/api/contracts/tools/uptimerobot'
5+
import { 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 { forwardPspRequest } from '@/app/api/tools/uptimerobot/server-utils'
10+
11+
export const dynamic = 'force-dynamic'
12+
13+
const logger = createLogger('UptimeRobotCreatePspAPI')
14+
15+
export const POST = withRouteHandler(async (request: NextRequest) => {
16+
const requestId = generateRequestId()
17+
18+
try {
19+
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
20+
if (!authResult.success || !authResult.userId) {
21+
logger.warn(`[${requestId}] Unauthorized UptimeRobot create-psp request: ${authResult.error}`)
22+
return NextResponse.json(
23+
{ success: false, error: authResult.error || 'Authentication required' },
24+
{ status: 401 }
25+
)
26+
}
27+
28+
const parsed = await parseRequest(uptimeRobotCreatePspContract, request, {})
29+
if (!parsed.success) return parsed.response
30+
const body = parsed.data.body
31+
32+
return forwardPspRequest({
33+
apiKey: body.apiKey,
34+
method: 'POST',
35+
path: '/psps',
36+
fields: body,
37+
userId: authResult.userId,
38+
requestId,
39+
logger,
40+
})
41+
} catch (error) {
42+
logger.error(`[${requestId}] Unexpected error creating status page:`, error)
43+
return NextResponse.json(
44+
{ success: false, error: getErrorMessage(error, 'Unknown error') },
45+
{ status: 500 }
46+
)
47+
}
48+
})
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import type { Logger } from '@sim/logger'
2+
import { NextResponse } from 'next/server'
3+
import { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/file-utils'
4+
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
5+
import { assertToolFileAccess } from '@/app/api/files/authorization'
6+
import { mapPsp, UPTIMEROBOT_API_BASE } from '@/tools/uptimerobot/types'
7+
8+
/** Fields shared by the PSP create and update routes (before the files). */
9+
interface PspFormFields {
10+
friendlyName?: string | null
11+
monitorIds?: string | null
12+
status?: string | null
13+
password?: string | null
14+
customDomain?: string | null
15+
hideUrlLinks?: boolean | null
16+
noIndex?: boolean | null
17+
logo?: unknown
18+
icon?: unknown
19+
}
20+
21+
/**
22+
* Appends a single optional image file (logo or icon) to the form after
23+
* downloading it from storage and verifying the caller may access it.
24+
*
25+
* @returns an error `NextResponse` if the file is invalid or access is denied,
26+
* otherwise `null`.
27+
*/
28+
async function appendPspImage(
29+
form: FormData,
30+
field: 'logo' | 'icon',
31+
file: unknown,
32+
userId: string,
33+
requestId: string,
34+
logger: Logger
35+
): Promise<NextResponse | null> {
36+
const userFiles = processFilesToUserFiles([file as RawFileInput], requestId, logger)
37+
if (userFiles.length === 0) {
38+
// A file was supplied but could not be resolved to a stored UserFile (e.g. a
39+
// bare string reference). Surface it rather than silently dropping the image.
40+
return NextResponse.json(
41+
{ success: false, error: `Invalid ${field} file: expected an uploaded file reference` },
42+
{ status: 400 }
43+
)
44+
}
45+
46+
const userFile = userFiles[0]
47+
const denied = await assertToolFileAccess(userFile.key, userId, requestId, logger)
48+
if (denied) return denied
49+
50+
const buffer = await downloadFileFromStorage(userFile, requestId, logger)
51+
const mimeType = userFile.type || 'application/octet-stream'
52+
form.append(field, new Blob([new Uint8Array(buffer)], { type: mimeType }), userFile.name)
53+
return null
54+
}
55+
56+
/**
57+
* Builds the multipart form for a PSP request, downloads any referenced
58+
* logo/icon files, forwards the request to UptimeRobot, and returns a typed
59+
* `{ success, output: { psp } }` envelope as a `NextResponse`.
60+
*/
61+
export async function forwardPspRequest(options: {
62+
apiKey: string
63+
method: 'POST' | 'PATCH'
64+
path: string
65+
fields: PspFormFields
66+
userId: string
67+
requestId: string
68+
logger: Logger
69+
}): Promise<NextResponse> {
70+
const { apiKey, method, path, fields, userId, requestId, logger } = options
71+
72+
const form = new FormData()
73+
if (fields.friendlyName) form.append('friendlyName', fields.friendlyName)
74+
if (fields.status) form.append('status', fields.status)
75+
if (fields.password) form.append('password', fields.password)
76+
if (fields.customDomain) form.append('customDomain', fields.customDomain)
77+
if (typeof fields.hideUrlLinks === 'boolean') {
78+
form.append('hideUrlLinks', String(fields.hideUrlLinks))
79+
}
80+
if (typeof fields.noIndex === 'boolean') form.append('noIndex', String(fields.noIndex))
81+
if (fields.monitorIds) {
82+
for (const id of fields.monitorIds.split(',')) {
83+
const trimmed = id.trim()
84+
if (trimmed) form.append('monitorIds', trimmed)
85+
}
86+
}
87+
88+
if (fields.logo) {
89+
const denied = await appendPspImage(form, 'logo', fields.logo, userId, requestId, logger)
90+
if (denied) return denied
91+
}
92+
if (fields.icon) {
93+
const denied = await appendPspImage(form, 'icon', fields.icon, userId, requestId, logger)
94+
if (denied) return denied
95+
}
96+
97+
const response = await fetch(`${UPTIMEROBOT_API_BASE}${path}`, {
98+
method,
99+
headers: { Authorization: `Bearer ${apiKey}`, Accept: 'application/json' },
100+
body: form,
101+
})
102+
103+
const text = await response.text()
104+
if (!response.ok) {
105+
let message: string | undefined
106+
try {
107+
message = JSON.parse(text)?.message
108+
} catch {
109+
message = undefined
110+
}
111+
logger.error(`[${requestId}] UptimeRobot PSP request failed`, {
112+
status: response.status,
113+
body: text,
114+
})
115+
return NextResponse.json(
116+
{ success: false, error: message || `UptimeRobot API error (HTTP ${response.status})` },
117+
{ status: response.status }
118+
)
119+
}
120+
121+
// A successful PSP create/update must return the PspDto object. An empty or
122+
// non-object body is unexpected — reject it rather than mapping a phantom PSP
123+
// (id: 0, empty name, null images) back to the workflow.
124+
if (!text) {
125+
logger.error(`[${requestId}] UptimeRobot returned an empty PSP response`)
126+
return NextResponse.json(
127+
{ success: false, error: 'UptimeRobot returned an unexpected response' },
128+
{ status: 502 }
129+
)
130+
}
131+
132+
let data: Record<string, unknown>
133+
try {
134+
const parsed = JSON.parse(text)
135+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
136+
throw new Error('Expected a PSP object response')
137+
}
138+
data = parsed as Record<string, unknown>
139+
} catch {
140+
logger.error(`[${requestId}] UptimeRobot returned an unexpected PSP response`, { body: text })
141+
return NextResponse.json(
142+
{ success: false, error: 'UptimeRobot returned an unexpected response' },
143+
{ status: 502 }
144+
)
145+
}
146+
147+
// A real PspDto always carries a positive numeric `id` and a non-empty
148+
// `friendlyName` (both spec-required). If they are absent, the body is a `{}`
149+
// or metadata envelope, not a status page — surface the provider error rather
150+
// than mapping a phantom PSP.
151+
if (typeof data.id !== 'number' || data.id < 1 || !data.friendlyName) {
152+
logger.error(`[${requestId}] UptimeRobot returned a PSP response without core fields`, {
153+
body: text,
154+
})
155+
return NextResponse.json(
156+
{ success: false, error: 'UptimeRobot returned an unexpected response' },
157+
{ status: 502 }
158+
)
159+
}
160+
161+
return NextResponse.json({ success: true, output: { psp: mapPsp(data) } })
162+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { createLogger } from '@sim/logger'
2+
import { getErrorMessage } from '@sim/utils/errors'
3+
import { type NextRequest, NextResponse } from 'next/server'
4+
import { uptimeRobotUpdatePspContract } from '@/lib/api/contracts/tools/uptimerobot'
5+
import { 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 { forwardPspRequest } from '@/app/api/tools/uptimerobot/server-utils'
10+
11+
export const dynamic = 'force-dynamic'
12+
13+
const logger = createLogger('UptimeRobotUpdatePspAPI')
14+
15+
export const POST = withRouteHandler(async (request: NextRequest) => {
16+
const requestId = generateRequestId()
17+
18+
try {
19+
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
20+
if (!authResult.success || !authResult.userId) {
21+
logger.warn(`[${requestId}] Unauthorized UptimeRobot update-psp request: ${authResult.error}`)
22+
return NextResponse.json(
23+
{ success: false, error: authResult.error || 'Authentication required' },
24+
{ status: 401 }
25+
)
26+
}
27+
28+
const parsed = await parseRequest(uptimeRobotUpdatePspContract, request, {})
29+
if (!parsed.success) return parsed.response
30+
const body = parsed.data.body
31+
32+
return forwardPspRequest({
33+
apiKey: body.apiKey,
34+
method: 'PATCH',
35+
path: `/psps/${body.pspId}`,
36+
fields: body,
37+
userId: authResult.userId,
38+
requestId,
39+
logger,
40+
})
41+
} catch (error) {
42+
logger.error(`[${requestId}] Unexpected error updating status page:`, error)
43+
return NextResponse.json(
44+
{ success: false, error: getErrorMessage(error, 'Unknown error') },
45+
{ status: 500 }
46+
)
47+
}
48+
})

0 commit comments

Comments
 (0)