From abb129f88458cb5c2e192c2f47961d07144075af Mon Sep 17 00:00:00 2001 From: Moskovkin Ilya Date: Sun, 12 Jul 2026 13:22:08 +0700 Subject: [PATCH 1/3] feat: add public event validator --- scripts/test-signalops-events.mjs | 265 ++++++++++++ src/app/api/events/validate/route.ts | 12 + src/app/validate/page.tsx | 524 ++++++++++++++++++++++++ src/lib/signalops/events.ts | 579 +++++++++++++++++++++++++++ 4 files changed, 1380 insertions(+) create mode 100644 scripts/test-signalops-events.mjs create mode 100644 src/app/api/events/validate/route.ts create mode 100644 src/app/validate/page.tsx create mode 100644 src/lib/signalops/events.ts diff --git a/scripts/test-signalops-events.mjs b/scripts/test-signalops-events.mjs new file mode 100644 index 0000000..d4a032d --- /dev/null +++ b/scripts/test-signalops-events.mjs @@ -0,0 +1,265 @@ +import assert from "node:assert/strict"; + +import { + SIGNALOPS_EVENT_LIMITS, + getUtf8ByteLength, + normalizeSignalEvent, + normalizeSignalEventBatch, + summarizeSignalEventValidation, + validateSignalEventRequest, +} from "../src/lib/signalops/events.ts"; + +const NOW = Date.parse("2026-07-12T12:30:00.000Z"); +const requestNow = Date.now(); + +function isoOffset(offsetMs) { + return new Date(requestNow + offsetMs).toISOString(); +} + +function jsonRequest(body, headers = {}) { + return new Request("http://localhost/api/events/validate", { + method: "POST", + headers: { + "content-type": "application/json", + ...headers, + }, + body, + }); +} + +const deterministicInput = { + type: "generation.completed", + occurredAt: "2026-07-12T12:00:00.000Z", + generationId: "gen_contract_001", + providerId: "fal", + modelId: "flux-kontext-pro", + source: "public-api", + status: "succeeded", + durationMs: 18340, + cost: 0.057, + user: "ops@signalops.cc", + prompt: "sensitive prompt", +}; + +const derivedEvent = normalizeSignalEvent(deterministicInput, { + currentTimeMs: NOW, + receivedAt: "2026-07-12T12:30:00.000Z", +}); +const derivedEventAgain = normalizeSignalEvent(deterministicInput, { + currentTimeMs: NOW, + receivedAt: "2026-07-12T12:30:00.000Z", +}); + +assert.equal(derivedEvent.generationId, "gen_contract_001"); +assert.equal(derivedEventAgain.generationId, "gen_contract_001"); +assert.equal(derivedEvent.eventId, derivedEventAgain.eventId); +assert.match(derivedEvent.eventId, /^generation\.completed:gen_contract_001:[0-9a-f]{8}$/); +assert.equal(derivedEvent.user, "[redacted]"); +assert.equal(derivedEvent.prompt, "[redacted]"); + +const firstRetry = normalizeSignalEvent( + { ...deterministicInput, type: "generation.retrying", retryCount: 1 }, + { currentTimeMs: NOW }, +); +const secondRetry = normalizeSignalEvent( + { + ...deterministicInput, + type: "generation.retrying", + occurredAt: "2026-07-12T12:01:00.000Z", + retryCount: 2, + }, + { currentTimeMs: NOW }, +); +assert.notEqual(firstRetry.eventId, secondRetry.eventId); + +const partialBatch = normalizeSignalEventBatch( + { + events: [ + { + type: "generation.completed", + occurredAt: "2026-07-12T12:00:00.000Z", + generationId: "gen_partial_001", + providerId: "fal", + modelId: "flux-kontext-pro", + source: "public-api", + durationMs: 1, + cost: 0, + }, + { + type: "generation.retrying", + occurredAt: "3026-07-12T12:00:00.000Z", + generationId: "gen_partial_001", + providerId: "fal", + modelId: "flux-kontext-pro", + retryCount: 1.2, + }, + ], + }, + { currentTimeMs: NOW, receivedAt: "2026-07-12T12:30:00.000Z" }, +); + +assert.equal(partialBatch.events.length, 1); +assert.equal(partialBatch.rejected.length, 1); +assert.match(partialBatch.rejected[0].error, /future|integer/); + +const summary = summarizeSignalEventValidation(partialBatch, "redact"); +assert.equal(summary.validEvents, 1); +assert.equal(summary.rejectedEvents, 1); +assert.equal(summary.storedEvents, 0); + +assert.throws( + () => + normalizeSignalEvent( + { ...deterministicInput, occurredAt: "July 12, 2026 12:00:00 UTC" }, + { currentTimeMs: NOW }, + ), + /UTC ISO-8601 format/, +); + +const retryOnlySummary = summarizeSignalEventValidation( + normalizeSignalEventBatch( + { + ...deterministicInput, + type: "generation.retrying", + retryCount: 1, + }, + { currentTimeMs: NOW }, + ), +); +assert.ok(retryOnlySummary.diagnostics.gaps.some((gap) => gap.includes("outcome"))); +assert.equal(retryOnlySummary.diagnostics.coverage.retries, true); + +const invalidJsonResponse = await validateSignalEventRequest( + new Request("http://localhost/api/events/validate", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{not-json", + }), +); +assert.equal(invalidJsonResponse.status, 400); +assert.equal(invalidJsonResponse.body.code, "invalid_json"); + +const unsupportedResponse = await validateSignalEventRequest( + new Request("http://localhost/api/events/validate", { + method: "POST", + headers: { "content-type": "text/plain" }, + body: "{}", + }), +); +assert.equal(unsupportedResponse.status, 415); + +const lookalikeContentTypeResponse = await validateSignalEventRequest( + new Request("http://localhost/api/events/validate", { + method: "POST", + headers: { "content-type": "application/json-patch+json" }, + body: "{}", + }), +); +assert.equal(lookalikeContentTypeResponse.status, 415); + +const tooLargePayload = "x".repeat(SIGNALOPS_EVENT_LIMITS.maxBodyBytes + 1); +assert.equal(getUtf8ByteLength(tooLargePayload), SIGNALOPS_EVENT_LIMITS.maxBodyBytes + 1); +const tooLargeResponse = await validateSignalEventRequest( + jsonRequest(`"${tooLargePayload}"`, { + "content-length": String(SIGNALOPS_EVENT_LIMITS.maxBodyBytes + 10), + }), +); +assert.equal(tooLargeResponse.status, 413); + +const actualUtf8TooLargeResponse = await validateSignalEventRequest( + jsonRequest(JSON.stringify("界".repeat(Math.ceil(SIGNALOPS_EVENT_LIMITS.maxBodyBytes / 3) + 1))), +); +assert.equal(actualUtf8TooLargeResponse.status, 413); + +assert.throws( + () => + normalizeSignalEventBatch( + { events: Array.from({ length: SIGNALOPS_EVENT_LIMITS.maxBatchEvents + 1 }, () => ({})) }, + { currentTimeMs: NOW }, + ), + /limited to 100 events/, +); + +const validResponse = await validateSignalEventRequest( + jsonRequest( + JSON.stringify({ + events: [ + { + type: "generation.started", + occurredAt: isoOffset(-180_000), + generationId: "gen_request_001", + providerId: "fal", + modelId: "flux-kontext-pro", + source: "public-api", + status: "running", + user: "ops@signalops.cc", + prompt: "private prompt", + }, + { + type: "generation.retrying", + occurredAt: isoOffset(-120_000), + generationId: "gen_request_001", + providerId: "fal", + modelId: "flux-kontext-pro", + source: "public-api", + status: "retrying", + retryCount: 1, + durationMs: 5100, + }, + { + type: "generation.completed", + occurredAt: isoOffset(-60_000), + generationId: "gen_request_001", + providerId: "fal", + modelId: "flux-kontext-pro", + source: "public-api", + status: "succeeded", + durationMs: 18340, + cost: 0.057, + }, + { + type: "provider.health", + occurredAt: isoOffset(-30_000), + providerId: "fal", + source: "health-monitor", + statusMessage: "Recovered", + }, + ], + }), + ), +); + +assert.equal(validResponse.status, 200); +const validJson = validResponse.body; +assert.equal(validJson.ok, true); +assert.equal(validJson.verificationOnly, true); +assert.equal(validJson.storedEvents, 0); +assert.equal(validJson.validEvents, 4); +assert.equal(validJson.rejectedEvents, 0); +assert.equal(validJson.acceptedPreview[0].user, "[redacted]"); +assert.equal(validJson.acceptedPreview[0].prompt, "[redacted]"); + +const noValidResponse = await validateSignalEventRequest( + jsonRequest( + JSON.stringify({ + events: [ + { + type: "generation.completed", + occurredAt: "3026-07-12T12:00:00.000Z", + generationId: "gen_invalid_001", + providerId: "fal", + modelId: "flux-kontext-pro", + durationMs: -1, + }, + ], + }), + ), +); + +assert.equal(noValidResponse.status, 422); +const noValidJson = noValidResponse.body; +assert.equal(noValidJson.ok, false); +assert.equal(noValidJson.validEvents, 0); +assert.equal(noValidJson.rejectedEvents, 1); + +console.log("signalops event validator checks passed"); diff --git a/src/app/api/events/validate/route.ts b/src/app/api/events/validate/route.ts new file mode 100644 index 0000000..28960cc --- /dev/null +++ b/src/app/api/events/validate/route.ts @@ -0,0 +1,12 @@ +import { NextResponse } from "next/server.js"; + +import { + validateSignalEventRequest, +} from "../../../../lib/signalops/events"; + +export const runtime = "nodejs"; + +export async function POST(request: Request) { + const response = await validateSignalEventRequest(request); + return NextResponse.json(response.body, { status: response.status }); +} diff --git a/src/app/validate/page.tsx b/src/app/validate/page.tsx new file mode 100644 index 0000000..5ac632d --- /dev/null +++ b/src/app/validate/page.tsx @@ -0,0 +1,524 @@ +"use client"; + +import { CheckCircle2, Clipboard, Loader2, ShieldAlert, Sparkles, XCircle } from "lucide-react"; +import { useMemo, useState } from "react"; + +type ValidationResponse = { + ok: boolean; + code?: string; + error?: string; + partial?: boolean; + verificationOnly?: boolean; + storedEvents?: number; + validEvents?: number; + rejectedEvents?: number; + eventTypes?: string[]; + providerIds?: string[]; + modelIds?: string[]; + requestId?: string; + limits?: { + maxBodyBytes: number; + maxBatchEvents: number; + }; + diagnostics?: { + readiness: "insufficient" | "partial" | "pilot_ready"; + coverage: { + generationLifecycle: { + started: boolean; + completed: boolean; + failed: boolean; + retrying: boolean; + }; + providerHealth: boolean; + latency: boolean; + cost: boolean; + retries: boolean; + providers: number; + models: number; + }; + gaps: string[]; + nextActions: string[]; + }; + rejected?: Array<{ index: number; error: string }>; + acceptedPreview?: Array>; +}; + +const samples = [ + { + id: "pilot-ready", + label: "Pilot-ready batch", + description: "Shows lifecycle, latency, cost, retries, and provider health coverage.", + payload: { + events: [ + { + type: "generation.started", + occurredAt: "2026-07-01T12:00:00.000Z", + generationId: "gen_public_001", + providerId: "fal", + modelId: "flux-kontext-pro", + source: "public-api", + status: "running", + user: "ops@signalops.cc", + prompt: "cinematic portrait with rain", + }, + { + type: "generation.retrying", + occurredAt: "2026-07-01T12:00:10.000Z", + generationId: "gen_public_001", + providerId: "fal", + modelId: "flux-kontext-pro", + source: "public-api", + status: "retrying", + retryCount: 1, + durationMs: 8200, + }, + { + type: "generation.completed", + occurredAt: "2026-07-01T12:00:31.000Z", + generationId: "gen_public_001", + providerId: "fal", + modelId: "flux-kontext-pro", + source: "public-api", + status: "succeeded", + durationMs: 18340, + cost: 0.057, + }, + { + type: "provider.health", + occurredAt: "2026-07-01T12:01:00.000Z", + providerId: "fal", + source: "health-monitor", + statusMessage: "Latency stabilized after regional failover.", + }, + ], + }, + }, + { + id: "partial", + label: "Partial batch", + description: "One accepted event and one rejected event to show batch-level feedback.", + payload: { + events: [ + { + type: "generation.completed", + occurredAt: "2026-07-01T11:55:00.000Z", + generationId: "gen_partial_001", + providerId: "alibaba", + modelId: "wan-2.2-image", + durationMs: 9110, + cost: 0.031, + user: "artist@example.com", + prompt: "product photo on brushed steel", + }, + { + type: "generation.retrying", + occurredAt: "3026-07-12T11:55:05.000Z", + generationId: "gen_partial_001", + providerId: "alibaba", + modelId: "wan-2.2-image", + retryCount: 1.5, + }, + ], + }, + }, + { + id: "invalid", + label: "Invalid event", + description: "Highlights strict JSON-contract errors before any protected ingest exists.", + payload: { + type: "generation.completed", + occurredAt: "3026-07-12T12:00:00.000Z", + providerId: "qwen", + modelId: "qwen-image-beta", + durationMs: -1, + cost: 0.02, + }, + }, +]; + +function formatJson(value: unknown) { + return JSON.stringify(value, null, 2); +} + +function readinessTone(readiness: ValidationResponse["diagnostics"] extends infer T + ? T extends { readiness: infer R } + ? R + : never + : never) { + if (readiness === "pilot_ready") { + return { + label: "Pilot ready", + className: "bg-[var(--success-soft)] text-[var(--success)] border-[var(--success)]/20", + }; + } + + if (readiness === "partial") { + return { + label: "Partial", + className: "bg-[var(--warning-soft)] text-[var(--warning)] border-[var(--warning)]/20", + }; + } + + return { + label: "Insufficient", + className: "bg-[var(--danger-soft)] text-[var(--danger)] border-[var(--danger)]/20", + }; +} + +export default function ValidatePage() { + const [payload, setPayload] = useState(formatJson(samples[0].payload)); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const [isValidating, setIsValidating] = useState(false); + const [copied, setCopied] = useState(false); + + const parsedPayload = useMemo(() => { + try { + return { ok: true as const, value: JSON.parse(payload) as unknown }; + } catch (parseError) { + return { + ok: false as const, + error: parseError instanceof Error ? parseError.message : "Invalid JSON", + }; + } + }, [payload]); + + const readiness = result?.diagnostics ? readinessTone(result.diagnostics.readiness) : null; + const statusIcon = result?.ok + ? CheckCircle2 + : error || result + ? XCircle + : ShieldAlert; + + async function runValidation() { + setCopied(false); + setError(null); + setResult(null); + + if (!parsedPayload.ok) { + setError(parsedPayload.error); + return; + } + + setIsValidating(true); + try { + const response = await fetch("/api/events/validate", { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: formatJson(parsedPayload.value), + }); + const json = (await response.json()) as ValidationResponse; + setResult(json); + if (!response.ok) { + setError(json.error ?? json.code ?? `Validation failed with ${response.status}`); + } + } catch (requestError) { + setError(requestError instanceof Error ? requestError.message : "Validation request failed"); + } finally { + setIsValidating(false); + } + } + + async function copyPayload() { + try { + await navigator.clipboard.writeText(payload); + setCopied(true); + } catch (clipboardError) { + setError(clipboardError instanceof Error ? clipboardError.message : "Copy failed"); + } + } + + function loadSample(sampleId: string) { + const sample = samples.find((entry) => entry.id === sampleId); + if (!sample) { + return; + } + + setCopied(false); + setError(null); + setResult(null); + setPayload(formatJson(sample.payload)); + } + + const StatusIcon = statusIcon; + + return ( +
+
+
+
+
+
+ + Public validator +
+

+ Validate SignalOps event payloads before you wire protected ingest. +

+

+ This endpoint is public, zero-storage, and strict on shape. It validates a single event or a + bounded batch, redacts `user` and `prompt`, and returns readiness feedback plus the gaps + still blocking a real ingest path. +

+
+ +
+
+
+

+ Request bounds +

+

256 KB

+

Header and actual UTF-8 body both enforced.

+
+
+

+ Batch bounds +

+

100 events

+

Partial rejection supported inside the limit.

+
+
+
+

+ Current contract +

+
    +
  • Public `POST /api/events/validate` accepts `application/json` only.
  • +
  • Telemetry must be finite and nonnegative; `retryCount` must be an integer.
  • +
  • Future timestamps are rejected. Accepted previews are redacted and never stored.
  • +
+
+
+
+
+ +
+
+
+
+

+ Editable playground +

+

+ Start with a sample, edit the JSON, then run the same validator response your integration + would receive from the public dry-run endpoint. +

+
+
+ {samples.map((sample) => ( + + ))} +
+
+ +
+ {samples.map((sample) => ( +
+ {sample.label}: {sample.description} +
+ ))} +
+ +