diff --git a/apps/web/src/app/api/kiloclaw/clawmetry/[instanceId]/route.ts b/apps/web/src/app/api/kiloclaw/clawmetry/[instanceId]/route.ts new file mode 100644 index 0000000000..d17c6f7da1 --- /dev/null +++ b/apps/web/src/app/api/kiloclaw/clawmetry/[instanceId]/route.ts @@ -0,0 +1,88 @@ +import { NextResponse } from 'next/server'; +import { getUserFromAuth } from '@/lib/user.server'; +import { KILOCLAW_API_URL } from '@/lib/config.server'; +import { generateApiToken, TOKEN_EXPIRY } from '@/lib/tokens'; + +/** + * POST /api/kiloclaw/clawmetry/[instanceId] + * + * One-shot endpoint backing the "View Observability Dashboard" button. Does + * two things on the user's KiloClaw instance and returns the dashboard URL + * the browser should open in a new tab: + * + * 1. POST `/_kilo/clawmetry-start-sync` — spawns the sync daemon (idempotent) + * 2. GET `/_kilo/clawmetry-dashboard-url` — reads the self-decrypting URL + * + * Both calls go through the existing per-instance worker proxy + * (`{KILOCLAW_API_URL}/i/{instanceId}/...`) which already handles JWT auth, + * access control, and the gateway-token signing for the controller. + * + * The returned URL contains the AES-256-GCM enc_key in its `#fragment` — + * that fragment is meaningful only to the browser (servers never see it). + * The browser stashes the key in localStorage and decrypts ClawMetry + * events client-side. + */ +export async function POST(_req: Request, { params }: { params: Promise<{ instanceId: string }> }) { + const { instanceId } = await params; + if (!instanceId || !/^[A-Za-z0-9_-]+$/.test(instanceId)) { + return NextResponse.json({ error: 'Invalid instance ID' }, { status: 400 }); + } + + const { user, authFailedResponse } = await getUserFromAuth({ adminOnly: false }); + if (authFailedResponse) return authFailedResponse; + + if (!KILOCLAW_API_URL) { + return NextResponse.json({ error: 'KiloClaw not configured' }, { status: 503 }); + } + + const token = generateApiToken(user, undefined, { expiresIn: TOKEN_EXPIRY.fiveMinutes }); + const proxyBase = `${KILOCLAW_API_URL}/i/${encodeURIComponent(instanceId)}`; + const headers = { Authorization: `Bearer ${token}` }; + + // 1. Start the sync daemon — idempotent. Failure here is non-fatal; we + // still try to return the dashboard URL so the user can at least see + // historical data (or an empty dashboard with a clear error state). + try { + const startRes = await fetch(`${proxyBase}/_kilo/clawmetry-start-sync`, { + method: 'POST', + headers, + }); + if (!startRes.ok && startRes.status !== 404) { + console.warn(`[clawmetry] start-sync returned ${startRes.status} for instance ${instanceId}`); + } + } catch (err) { + console.warn(`[clawmetry] start-sync error for instance ${instanceId}:`, err); + } + + // 2. Fetch the self-decrypting dashboard URL. + let urlRes: Response; + try { + urlRes = await fetch(`${proxyBase}/_kilo/clawmetry-dashboard-url`, { headers }); + } catch (err) { + console.error(`[clawmetry] dashboard-url fetch failed for instance ${instanceId}:`, err); + return NextResponse.json({ error: 'Failed to reach instance' }, { status: 502 }); + } + + if (urlRes.status === 404) { + return NextResponse.json( + { + error: + 'ClawMetry not provisioned on this instance — try redeploying or check KILOCLAW_CLAWMETRY_DISABLED env var', + }, + { status: 404 } + ); + } + if (!urlRes.ok) { + return NextResponse.json( + { error: `Instance returned ${urlRes.status}` }, + { status: urlRes.status } + ); + } + + const body = (await urlRes.json()) as { url?: string }; + if (!body.url) { + return NextResponse.json({ error: 'Instance returned no dashboard URL' }, { status: 502 }); + } + + return NextResponse.json({ url: body.url }); +} diff --git a/apps/web/src/components/subscriptions/kiloclaw/KiloClawDetail.tsx b/apps/web/src/components/subscriptions/kiloclaw/KiloClawDetail.tsx index 6c14c33e98..49c6d9f2ff 100644 --- a/apps/web/src/components/subscriptions/kiloclaw/KiloClawDetail.tsx +++ b/apps/web/src/components/subscriptions/kiloclaw/KiloClawDetail.tsx @@ -342,6 +342,32 @@ export function KiloClawDetail({ instanceId }: { instanceId: string }) { )} + + {subscription.showConversionPrompt ? (