wulf-pulse/app/api/rmm/loglift/upload/route.ts
lorentz 1112a06afe feat: RMM Overshell, IT Glue audit/write-back, LogLift, link-aware bundles, dashboard overhaul
- RMM Overshell (migration 077): admin page, dispatch UI, executor/worker, target
  resolver, script registry (AD/DHCP/DNS/event-log/services/software/network/loglift)
- LogLift evidence pipeline (migration 078): upload webhook, B2 storage client,
  receiver/matcher, EventLogCollector PowerShell script
- IT Glue audit + write-back (migrations 075, 076): asset-audit runner, ticket
  xrefs, applications/configurations browse pages + apply/revert/audit endpoints
- Link-aware analyzer bundles (migration 073) + provider toggle (migration 074):
  link-discovery service, OpenRouter LLM provider, related-tickets/itglue-suggestion
  panels, analyze-bundle endpoint
- Endpoint data model + device-link reconciliation (migrations 079, 080): conflicts
  admin page, reconciler service, resolve endpoints
- Dashboard overhaul: integration-health service + alerts, overview/health endpoints
- Permissions: add itglue + rmm scopes; middleware: public /api/rmm/loglift route

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 07:13:18 -04:00

99 lines
3 KiB
TypeScript

/**
* LogLift evidence webhook.
*
* The Datto RMM collector component uploads gzipped event-log JSON to B2,
* then POSTs the metadata here. Pulse downloads the gzip, slims it, persists
* it as RMM evidence, and (when the hostname matches a single IT Glue
* Configuration) fires an asset-first audit on the side.
*
* Auth: `x-openclaw-key` header — same shared secret the collector already
* carries for OpenClaw API calls.
*
* Public route per `middleware.ts` (`/api/rmm/loglift` exclusion).
*/
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { validateOpenClawKey } from '@/lib/utils/openclaw-auth';
import { OBJECT_KEY_REGEX } from '@/lib/services/b2/client';
import { processLogliftWebhook } from '@/lib/services/rmm/loglift-receiver';
export const dynamic = 'force-dynamic';
export const maxDuration = 60;
const WebhookSchema = z.object({
runId: z.string().min(1).max(200),
clientId: z.string().min(1).max(200),
computerName: z.string().min(1).max(200),
deviceUid: z.string().max(200).nullable().optional(),
summary: z.object({
totalEvents: z.number().int().nonnegative().nullable().optional(),
criticalEvents: z.number().int().nonnegative().nullable().optional(),
errorCount: z.number().int().nonnegative().nullable().optional(),
warningCount: z.number().int().nonnegative().nullable().optional(),
timeRange: z.string().max(200).nullable().optional(),
}),
objectKey: z
.string()
.min(1)
.max(500)
.refine((s) => OBJECT_KEY_REGEX.test(s), {
message: 'objectKey does not match the required eventlogs path shape',
}),
collectedAt: z.string().min(1).max(64),
rmmContext: z
.object({
siteName: z.string().max(200).nullable().optional(),
siteUid: z.string().max(200).nullable().optional(),
accountUid: z.string().max(200).nullable().optional(),
})
.partial()
.nullable()
.optional(),
issueDescription: z.string().max(4000).nullable().optional(),
ticketNumber: z.string().max(64).nullable().optional(),
});
export async function POST(req: NextRequest) {
const auth = validateOpenClawKey(req);
if (auth) return auth;
let raw: unknown;
try {
raw = await req.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
}
const parsed = WebhookSchema.safeParse(raw);
if (!parsed.success) {
return NextResponse.json(
{
error: 'Invalid payload',
details: parsed.error.flatten(),
},
{ status: 400 }
);
}
try {
const result = await processLogliftWebhook(parsed.data);
return NextResponse.json(
{
executionId: result.executionId,
matched: result.matched,
parsed: result.parsed,
auditId: result.audit_id,
},
{ status: 200 }
);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error('[LOGLIFT-WEBHOOK]', message, err);
return NextResponse.json(
{ error: 'Failed to process LogLift webhook', message },
{ status: 500 }
);
}
}