From 5be7b229531f9bb88bc36d861213dc7288280d27 Mon Sep 17 00:00:00 2001 From: lorentz Date: Tue, 17 Mar 2026 23:26:44 -0400 Subject: [PATCH] feat: OpenClaw external agent sync API at /api/openclaw/* - API key auth via x-openclaw-key header (OPENCLAW_API_KEY env var) - GET /api/openclaw/sync/status - POST /api/openclaw/sync/autotask/incremental - POST /api/openclaw/sync/autotask/full - POST /api/openclaw/sync/autotask/entity { entities: [...] } - POST /api/openclaw/sync/datto-rmm { syncType: full|incremental } - POST /api/openclaw/sync/sentinelone - POST /api/openclaw/sync/veeam { syncType: full|incremental } - POST /api/openclaw/sync/zoom - POST /api/openclaw/sync/engagement - POST /api/openclaw/sync/qbo { syncType: full|incremental } - POST /api/openclaw/sync/zabbix - POST /api/openclaw/sync/itglue All routes bypass Better Auth middleware, delegate to existing sync services --- .../openclaw/sync/autotask/entity/route.ts | 56 +++++++++++++++++++ app/api/openclaw/sync/autotask/full/route.ts | 40 +++++++++++++ .../sync/autotask/incremental/route.ts | 37 ++++++++++++ app/api/openclaw/sync/datto-rmm/route.ts | 39 +++++++++++++ app/api/openclaw/sync/engagement/route.ts | 34 +++++++++++ app/api/openclaw/sync/itglue/route.ts | 26 +++++++++ app/api/openclaw/sync/qbo/route.ts | 35 ++++++++++++ app/api/openclaw/sync/sentinelone/route.ts | 26 +++++++++ app/api/openclaw/sync/status/route.ts | 28 ++++++++++ app/api/openclaw/sync/veeam/route.ts | 39 +++++++++++++ app/api/openclaw/sync/zabbix/route.ts | 35 ++++++++++++ app/api/openclaw/sync/zoom/route.ts | 31 ++++++++++ lib/utils/openclaw-auth.ts | 22 ++++++++ middleware.ts | 2 + 14 files changed, 450 insertions(+) create mode 100644 app/api/openclaw/sync/autotask/entity/route.ts create mode 100644 app/api/openclaw/sync/autotask/full/route.ts create mode 100644 app/api/openclaw/sync/autotask/incremental/route.ts create mode 100644 app/api/openclaw/sync/datto-rmm/route.ts create mode 100644 app/api/openclaw/sync/engagement/route.ts create mode 100644 app/api/openclaw/sync/itglue/route.ts create mode 100644 app/api/openclaw/sync/qbo/route.ts create mode 100644 app/api/openclaw/sync/sentinelone/route.ts create mode 100644 app/api/openclaw/sync/status/route.ts create mode 100644 app/api/openclaw/sync/veeam/route.ts create mode 100644 app/api/openclaw/sync/zabbix/route.ts create mode 100644 app/api/openclaw/sync/zoom/route.ts create mode 100644 lib/utils/openclaw-auth.ts diff --git a/app/api/openclaw/sync/autotask/entity/route.ts b/app/api/openclaw/sync/autotask/entity/route.ts new file mode 100644 index 0000000..294c0ed --- /dev/null +++ b/app/api/openclaw/sync/autotask/entity/route.ts @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { validateOpenClawKey } from '@/lib/utils/openclaw-auth'; +import { AutotaskClient } from '@/lib/services/autotask-client'; +import { createSyncService } from '@/lib/services/sync-service'; +import { EntityType, SyncType } from '@/lib/types/sync'; +import { isValidEntityType } from '@/lib/utils/sync-helpers'; + +export async function POST(request: NextRequest) { + const authError = validateOpenClawKey(request); + if (authError) return authError; + + try { + const body = await request.json(); + const { entities, yearsBack } = body; + + if (!entities || !Array.isArray(entities) || entities.length === 0) { + return NextResponse.json({ error: 'entities array is required' }, { status: 400 }); + } + + const validEntities: EntityType[] = []; + for (const entity of entities) { + if (isValidEntityType(entity)) { + validEntities.push(entity as EntityType); + } else { + return NextResponse.json({ error: `Invalid entity type: ${entity}` }, { status: 400 }); + } + } + + const autotaskClient = new AutotaskClient({ + apiUrl: process.env.AUTOTASK_API_URL || '', + username: process.env.AUTOTASK_USERNAME || '', + password: process.env.AUTOTASK_SECRET || '', + apiIntegrationCode: process.env.AUTOTASK_API_INTEGRATION_CODE || '', + }); + + const syncService = createSyncService(autotaskClient); + + if (syncService.isSyncInProgress()) { + return NextResponse.json({ error: 'A sync operation is already in progress' }, { status: 409 }); + } + + syncService.syncEntities(validEntities, 'entity-specific' as SyncType, 'openclaw', yearsBack).catch((err) => { + console.error('[OpenClaw] Autotask entity sync failed:', err); + }); + + return NextResponse.json({ + message: `Autotask entity sync started for: ${validEntities.join(', ')}`, + syncId: syncService.getCurrentSyncId(), + entities: validEntities, + triggeredBy: 'openclaw', + }); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + return NextResponse.json({ error: msg }, { status: 500 }); + } +} diff --git a/app/api/openclaw/sync/autotask/full/route.ts b/app/api/openclaw/sync/autotask/full/route.ts new file mode 100644 index 0000000..14cd372 --- /dev/null +++ b/app/api/openclaw/sync/autotask/full/route.ts @@ -0,0 +1,40 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { validateOpenClawKey } from '@/lib/utils/openclaw-auth'; +import { AutotaskClient } from '@/lib/services/autotask-client'; +import { createSyncService } from '@/lib/services/sync-service'; + +export async function POST(request: NextRequest) { + const authError = validateOpenClawKey(request); + if (authError) return authError; + + try { + const body = await request.json().catch(() => ({})); + const yearsBack = body.yearsBack; + + const autotaskClient = new AutotaskClient({ + apiUrl: process.env.AUTOTASK_API_URL || '', + username: process.env.AUTOTASK_USERNAME || '', + password: process.env.AUTOTASK_SECRET || '', + apiIntegrationCode: process.env.AUTOTASK_API_INTEGRATION_CODE || '', + }); + + const syncService = createSyncService(autotaskClient); + + if (syncService.isSyncInProgress()) { + return NextResponse.json({ error: 'A sync operation is already in progress' }, { status: 409 }); + } + + syncService.fullSync('openclaw', yearsBack).catch((err) => { + console.error('[OpenClaw] Autotask full sync failed:', err); + }); + + return NextResponse.json({ + message: 'Autotask full sync started', + syncId: syncService.getCurrentSyncId(), + triggeredBy: 'openclaw', + }); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + return NextResponse.json({ error: msg }, { status: 500 }); + } +} diff --git a/app/api/openclaw/sync/autotask/incremental/route.ts b/app/api/openclaw/sync/autotask/incremental/route.ts new file mode 100644 index 0000000..8078ef1 --- /dev/null +++ b/app/api/openclaw/sync/autotask/incremental/route.ts @@ -0,0 +1,37 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { validateOpenClawKey } from '@/lib/utils/openclaw-auth'; +import { AutotaskClient } from '@/lib/services/autotask-client'; +import { createSyncService } from '@/lib/services/sync-service'; + +export async function POST(request: NextRequest) { + const authError = validateOpenClawKey(request); + if (authError) return authError; + + try { + const autotaskClient = new AutotaskClient({ + apiUrl: process.env.AUTOTASK_API_URL || '', + username: process.env.AUTOTASK_USERNAME || '', + password: process.env.AUTOTASK_SECRET || '', + apiIntegrationCode: process.env.AUTOTASK_API_INTEGRATION_CODE || '', + }); + + const syncService = createSyncService(autotaskClient); + + if (syncService.isSyncInProgress()) { + return NextResponse.json({ error: 'A sync operation is already in progress' }, { status: 409 }); + } + + syncService.incrementalSync('openclaw').catch((err) => { + console.error('[OpenClaw] Autotask incremental sync failed:', err); + }); + + return NextResponse.json({ + message: 'Autotask incremental sync started', + syncId: syncService.getCurrentSyncId(), + triggeredBy: 'openclaw', + }); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + return NextResponse.json({ error: msg }, { status: 500 }); + } +} diff --git a/app/api/openclaw/sync/datto-rmm/route.ts b/app/api/openclaw/sync/datto-rmm/route.ts new file mode 100644 index 0000000..6ba0539 --- /dev/null +++ b/app/api/openclaw/sync/datto-rmm/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { validateOpenClawKey } from '@/lib/utils/openclaw-auth'; +import { DattoRMMSyncService } from '@/lib/services/datto-rmm-sync-service'; + +let syncServiceInstance: DattoRMMSyncService | null = null; +function getSyncService(): DattoRMMSyncService { + if (!syncServiceInstance) syncServiceInstance = new DattoRMMSyncService(); + return syncServiceInstance; +} + +export async function POST(request: NextRequest) { + const authError = validateOpenClawKey(request); + if (authError) return authError; + + try { + const syncService = getSyncService(); + + if (syncService.isSyncInProgress()) { + return NextResponse.json({ error: 'A Datto RMM sync is already in progress' }, { status: 409 }); + } + + const body = await request.json().catch(() => ({})); + const syncType = body.syncType === 'full' ? 'full' : 'incremental'; + + (syncType === 'full' + ? syncService.fullSync('openclaw') + : syncService.incrementalSync('openclaw') + ).catch((err) => console.error('[OpenClaw] Datto RMM sync failed:', err)); + + return NextResponse.json({ + message: `Datto RMM ${syncType} sync started`, + syncType, + triggeredBy: 'openclaw', + }); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + return NextResponse.json({ error: msg }, { status: 500 }); + } +} diff --git a/app/api/openclaw/sync/engagement/route.ts b/app/api/openclaw/sync/engagement/route.ts new file mode 100644 index 0000000..ee9b448 --- /dev/null +++ b/app/api/openclaw/sync/engagement/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { validateOpenClawKey } from '@/lib/utils/openclaw-auth'; +import { getEngagementSyncService } from '@/lib/services/engagement-sync-service'; +import { isMsgraphConfigured } from '@/lib/services/msgraph-factory'; + +export async function POST(request: NextRequest) { + const authError = validateOpenClawKey(request); + if (authError) return authError; + + if (!isMsgraphConfigured()) { + return NextResponse.json( + { error: 'Microsoft Graph not configured. Set MSGRAPH_CLIENT_ID, MSGRAPH_CLIENT_SECRET, MSGRAPH_TENANT_ID.' }, + { status: 503 } + ); + } + + try { + const service = getEngagementSyncService(); + + if (service.isSyncInProgress()) { + return NextResponse.json({ error: 'Engagement sync already in progress' }, { status: 409 }); + } + + service.sync().catch((err) => console.error('[OpenClaw] Engagement sync failed:', err)); + + return NextResponse.json({ + message: 'Engagement sync started', + triggeredBy: 'openclaw', + }); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + return NextResponse.json({ error: msg }, { status: 500 }); + } +} diff --git a/app/api/openclaw/sync/itglue/route.ts b/app/api/openclaw/sync/itglue/route.ts new file mode 100644 index 0000000..1cbadf4 --- /dev/null +++ b/app/api/openclaw/sync/itglue/route.ts @@ -0,0 +1,26 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { validateOpenClawKey } from '@/lib/utils/openclaw-auth'; +import { getITGlueSyncService } from '@/lib/services/itglue-sync-service'; + +export async function POST(request: NextRequest) { + const authError = validateOpenClawKey(request); + if (authError) return authError; + + try { + const svc = getITGlueSyncService(); + + if (svc.isSyncInProgress()) { + return NextResponse.json({ error: 'IT Glue sync already in progress' }, { status: 409 }); + } + + svc.fullSync('openclaw').catch((err) => console.error('[OpenClaw] IT Glue sync failed:', err)); + + return NextResponse.json({ + message: 'IT Glue sync started', + triggeredBy: 'openclaw', + }); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + return NextResponse.json({ error: msg }, { status: 500 }); + } +} diff --git a/app/api/openclaw/sync/qbo/route.ts b/app/api/openclaw/sync/qbo/route.ts new file mode 100644 index 0000000..e2b091c --- /dev/null +++ b/app/api/openclaw/sync/qbo/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { validateOpenClawKey } from '@/lib/utils/openclaw-auth'; +import { QboSyncService } from '@/lib/services/qbo-sync-service'; +import { QboClient } from '@/lib/services/qbo-client'; + +export async function POST(request: NextRequest) { + const authError = validateOpenClawKey(request); + if (authError) return authError; + + try { + const body = await request.json().catch(() => ({})); + const syncType: 'full' | 'incremental' = body.syncType === 'incremental' ? 'incremental' : 'full'; + + const client = new QboClient(); + const service = new QboSyncService(client); + + if (service.isSyncInProgress()) { + return NextResponse.json({ error: 'QBO sync already in progress' }, { status: 409 }); + } + + (syncType === 'full' + ? service.fullSync('openclaw') + : service.incrementalSync('openclaw') + ).catch((err) => console.error('[OpenClaw] QBO sync failed:', err)); + + return NextResponse.json({ + message: `QBO ${syncType} sync started`, + syncType, + triggeredBy: 'openclaw', + }); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + return NextResponse.json({ error: msg }, { status: 500 }); + } +} diff --git a/app/api/openclaw/sync/sentinelone/route.ts b/app/api/openclaw/sync/sentinelone/route.ts new file mode 100644 index 0000000..0a10dc0 --- /dev/null +++ b/app/api/openclaw/sync/sentinelone/route.ts @@ -0,0 +1,26 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { validateOpenClawKey } from '@/lib/utils/openclaw-auth'; +import { getSentinelOneSyncService } from '@/lib/services/sentinelone-sync-service'; + +export async function POST(request: NextRequest) { + const authError = validateOpenClawKey(request); + if (authError) return authError; + + try { + const svc = getSentinelOneSyncService(); + + if (svc.isSyncInProgress()) { + return NextResponse.json({ error: 'SentinelOne sync already in progress' }, { status: 409 }); + } + + svc.fullSync('openclaw').catch((err) => console.error('[OpenClaw] SentinelOne sync failed:', err)); + + return NextResponse.json({ + message: 'SentinelOne sync started', + triggeredBy: 'openclaw', + }); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + return NextResponse.json({ error: msg }, { status: 500 }); + } +} diff --git a/app/api/openclaw/sync/status/route.ts b/app/api/openclaw/sync/status/route.ts new file mode 100644 index 0000000..2fef629 --- /dev/null +++ b/app/api/openclaw/sync/status/route.ts @@ -0,0 +1,28 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { validateOpenClawKey } from '@/lib/utils/openclaw-auth'; +import { AutotaskClient } from '@/lib/services/autotask-client'; +import { createSyncService } from '@/lib/services/sync-service'; + +export async function GET(request: NextRequest) { + const authError = validateOpenClawKey(request); + if (authError) return authError; + + try { + const autotaskClient = new AutotaskClient({ + apiUrl: process.env.AUTOTASK_API_URL || '', + username: process.env.AUTOTASK_USERNAME || '', + password: process.env.AUTOTASK_SECRET || '', + apiIntegrationCode: process.env.AUTOTASK_API_INTEGRATION_CODE || '', + }); + + const syncService = createSyncService(autotaskClient); + + return NextResponse.json({ + inProgress: syncService.isSyncInProgress(), + syncId: syncService.getCurrentSyncId(), + }); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + return NextResponse.json({ error: msg }, { status: 500 }); + } +} diff --git a/app/api/openclaw/sync/veeam/route.ts b/app/api/openclaw/sync/veeam/route.ts new file mode 100644 index 0000000..1a7ec78 --- /dev/null +++ b/app/api/openclaw/sync/veeam/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { validateOpenClawKey } from '@/lib/utils/openclaw-auth'; +import { VeeamSyncService } from '@/lib/services/veeam-sync-service'; + +let syncServiceInstance: VeeamSyncService | null = null; +function getSyncService(): VeeamSyncService { + if (!syncServiceInstance) syncServiceInstance = new VeeamSyncService(); + return syncServiceInstance; +} + +export async function POST(request: NextRequest) { + const authError = validateOpenClawKey(request); + if (authError) return authError; + + try { + const syncService = getSyncService(); + + if (syncService.isSyncInProgress()) { + return NextResponse.json({ error: 'A Veeam sync is already in progress' }, { status: 409 }); + } + + const body = await request.json().catch(() => ({})); + const syncType = body.syncType === 'full' ? 'full' : 'incremental'; + + (syncType === 'full' + ? syncService.fullSync('openclaw') + : syncService.incrementalSync('openclaw') + ).catch((err) => console.error('[OpenClaw] Veeam sync failed:', err)); + + return NextResponse.json({ + message: `Veeam ${syncType} sync started`, + syncType, + triggeredBy: 'openclaw', + }); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + return NextResponse.json({ error: msg }, { status: 500 }); + } +} diff --git a/app/api/openclaw/sync/zabbix/route.ts b/app/api/openclaw/sync/zabbix/route.ts new file mode 100644 index 0000000..7377334 --- /dev/null +++ b/app/api/openclaw/sync/zabbix/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { validateOpenClawKey } from '@/lib/utils/openclaw-auth'; + +export async function POST(request: NextRequest) { + const authError = validateOpenClawKey(request); + if (authError) return authError; + + if (!process.env.ZABBIX_API_URL || !process.env.ZABBIX_API_TOKEN) { + return NextResponse.json({ error: 'Zabbix not configured' }, { status: 503 }); + } + + try { + const baseUrl = process.env.NEXT_PUBLIC_BETTER_AUTH_URL || 'http://localhost:3100'; + + const [hostsRes, eventsRes] = await Promise.all([ + fetch(`${baseUrl}/api/zabbix/sync-hosts`, { method: 'POST' }), + fetch(`${baseUrl}/api/zabbix/sync-events`, { method: 'POST' }), + ]); + + const [hosts, events] = await Promise.all([ + hostsRes.json().catch(() => ({})), + eventsRes.json().catch(() => ({})), + ]); + + return NextResponse.json({ + message: 'Zabbix sync completed', + triggeredBy: 'openclaw', + hosts, + events, + }); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + return NextResponse.json({ error: msg }, { status: 500 }); + } +} diff --git a/app/api/openclaw/sync/zoom/route.ts b/app/api/openclaw/sync/zoom/route.ts new file mode 100644 index 0000000..805cf6e --- /dev/null +++ b/app/api/openclaw/sync/zoom/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { validateOpenClawKey } from '@/lib/utils/openclaw-auth'; +import { getZoomSyncService } from '@/lib/services/zoom-sync-service'; +import { isZoomConfigured } from '@/lib/services/zoom-factory'; + +export async function POST(request: NextRequest) { + const authError = validateOpenClawKey(request); + if (authError) return authError; + + if (!isZoomConfigured()) { + return NextResponse.json({ error: 'Zoom credentials not configured' }, { status: 503 }); + } + + try { + const service = getZoomSyncService(); + + if (service.isSyncInProgress()) { + return NextResponse.json({ error: 'Zoom sync already in progress' }, { status: 409 }); + } + + service.sync().catch((err) => console.error('[OpenClaw] Zoom sync failed:', err)); + + return NextResponse.json({ + message: 'Zoom sync started', + triggeredBy: 'openclaw', + }); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + return NextResponse.json({ error: msg }, { status: 500 }); + } +} diff --git a/lib/utils/openclaw-auth.ts b/lib/utils/openclaw-auth.ts new file mode 100644 index 0000000..01917de --- /dev/null +++ b/lib/utils/openclaw-auth.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from 'next/server'; + +/** + * Validate the OpenClaw API key from the request header. + * Returns a 401 NextResponse if invalid, or null if valid. + */ +export function validateOpenClawKey(request: NextRequest): NextResponse | null { + const expectedKey = process.env.OPENCLAW_API_KEY; + + if (!expectedKey) { + console.error('[OpenClaw] OPENCLAW_API_KEY is not set'); + return NextResponse.json({ error: 'OpenClaw API not configured' }, { status: 500 }); + } + + const providedKey = request.headers.get('x-openclaw-key'); + + if (!providedKey || providedKey !== expectedKey) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + return null; +} diff --git a/middleware.ts b/middleware.ts index f1678f5..1b0e8fd 100644 --- a/middleware.ts +++ b/middleware.ts @@ -20,6 +20,8 @@ const publicRoutes = [ "/api/integrations/status", // Legal pages required by Intuit "/legal", + // OpenClaw external agent API (auth via x-openclaw-key header) + "/api/openclaw", // Sync endpoints called by scheduler "/api/sync", "/api/datto-rmm/sync",