/*
RFC-0290: the single validation path shared by both invocation surfaces (HTTP
POST and MCP tools/call). Interprets the RFC-0288 closed JSON-Schema subset
against an unknown request body, and builds the normalized IntegrationEvent
handed to the delivery substrate on success.
- Do not dispatch — the caller passes the built event to ports.dispatch.send.
- RFC-1097: step 6 — compass.migrate codemod run
Mechanical v1 to v2 header migration across the workspace: 942 files rewritten — CHANGE_SUMMARY windows collapsed into history, forbidden v1 blocks stripped, KEY_DECISIONS seeded from @ai-invariant comments (5 files) or TODO placeholders (103 files), blocks reordered to canonical order.
- RFC-1097: sweep — werkstatt-engine clean
Sweep batch 4: 73 Compass headers on headerless engine files (certification, component-runtime, isolation, evolution, testing), real KEY_DECISIONS on 75 files (kernel, cache, dht, swim, gitmesh, runtime), ~80 purpose expansions (CONTRACT-02/PURPOSE-02), non-goals on 13 CONTRACT-03 files, CS-07 history literal fix repo-wide (253 files). Policy: .template.ts/.template.astro excludedPaths. werkstatt-engine now 0 diagnostics.
- RFC-1186: step 4 — manifest bound set, formBinding stamp, gate formId tagging
- RFC-1186: AGC-FORM-06 missing formId, strict FORM-04 preview binding, shared bound-set helpers (RFC-1186)
- RFC-1254: drop unused io-migration leftovers
RFC-0290, RFC-1169, RFC-1186
*/
import type {
CapabilityInputOutputSchema,
} from "@warpgogol/werkstatt-shared/ontology";
import type { IntegrationEvent } from "@warpgogol/werkstatt-shared/integration/port";
import {
deriveActionEventId,
type SiteBoundCapabilityRecord,
} from "@warpgogol/werkstatt-shared/agent";
/**
* RFC-1169: closed machine-readable violation vocabulary. Emitted at the
* validator source — `problems[].code` on the preview envelope projects it
* verbatim; never derive codes from `message` text downstream. Extending the
* union widens the agent-visible vocabulary and requires an RFC.
*/
export type ValidationErrorCode =
"NOT_OBJECT" | "UNKNOWN_PROPERTY" | "REQUIRED" | "TYPE" | "MIN_LENGTH" | "MAX_LENGTH" | "FORMAT";
export interface ValidationFieldError {
path: string;
message: string;
code: ValidationErrorCode;
}
export type ValidationOutcome =
{ ok: true; value: Record } | { ok: false; errors: ValidationFieldError[] };
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
function hasOwn(record: Record, key: string): boolean {
return Object.prototype.hasOwnProperty.call(record, key);
}
function validateFormat(value: string, format: string): boolean {
switch (format) {
case "email":
return EMAIL_RE.test(value);
case "uuid":
return UUID_RE.test(value);
case "date":
return DATE_RE.test(value);
case "uri":
try {
new URL(value);
return true;
} catch {
return false;
}
default:
return true;
}
}
/**
* Interpret the RFC-0288 closed JSON-Schema subset against `value`. Every
* violation is reported (not fail-fast) so a caller can present the full set.
*/
export function validateAgainstCapabilitySchema(
schema: CapabilityInputOutputSchema,
value: unknown,
): ValidationOutcome {
const errors: ValidationFieldError[] = [];
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return {
ok: false,
errors: [{ path: "", message: "must be a JSON object", code: "NOT_OBJECT" }],
};
}
const obj = value as Record;
for (const key of Object.keys(obj)) {
if (!hasOwn(schema.properties, key)) {
errors.push({ path: key, message: `unknown property "${key}"`, code: "UNKNOWN_PROPERTY" });
}
}
for (const key of schema.required ?? []) {
if (!hasOwn(obj, key) || obj[key] === undefined) {
errors.push({ path: key, message: "required", code: "REQUIRED" });
}
}
for (const [key, propSchema] of Object.entries(schema.properties)) {
if (!hasOwn(obj, key) || obj[key] === undefined) continue;
const fieldValue = obj[key];
const path = key;
if (propSchema.type === "string") {
if (typeof fieldValue !== "string") {
errors.push({ path, message: "must be a string", code: "TYPE" });
continue;
}
if (propSchema.minLength !== undefined && fieldValue.length < propSchema.minLength) {
errors.push({
path,
message: `must be at least ${propSchema.minLength} characters`,
code: "MIN_LENGTH",
});
}
if (propSchema.maxLength !== undefined && fieldValue.length > propSchema.maxLength) {
errors.push({
path,
message: `must be at most ${propSchema.maxLength} characters`,
code: "MAX_LENGTH",
});
}
if (propSchema.format && !validateFormat(fieldValue, propSchema.format)) {
errors.push({ path, message: `must match format "${propSchema.format}"`, code: "FORMAT" });
}
} else if (propSchema.type === "boolean") {
if (typeof fieldValue !== "boolean")
errors.push({ path, message: "must be a boolean", code: "TYPE" });
} else if (propSchema.type === "integer") {
if (typeof fieldValue !== "number" || !Number.isInteger(fieldValue)) {
errors.push({ path, message: "must be an integer", code: "TYPE" });
}
}
}
if (errors.length > 0) return { ok: false, errors };
return { ok: true, value: obj };
}
const CONTACT_FIELDS = ["name", "email", "phone"] as const;
/**
* Normalize validated action input into the delivery substrate's event shape.
* RFC-1112: when `idempotencyKey` is present the eventId is derived
* deterministically from it (same key → same eventId → QStash dedup and the
* receipt store agree). Without a key the eventId is a random UUID.
* RFC-1186: a bound capability tags `payload.formId` so downstream consumers
* can distinguish bound forms sharing a `kind` — parity with the web path.
*/
export function buildIntegrationEventFromAction(
capability: SiteBoundCapabilityRecord,
input: Record,
locale: string,
now: Date,
idempotencyKey?: string,
): IntegrationEvent {
if (!capability.integration) {
throw new Error(
`capability "${capability.id}" has no integration block — only sideEffect "write" capabilities dispatch events`,
);
}
const contact: Record = {};
for (const field of CONTACT_FIELDS) {
const v = input[field];
if (typeof v === "string" && v.length > 0) contact[field] = v;
}
const eventId = idempotencyKey
? deriveActionEventId(capability.id, idempotencyKey)
: crypto.randomUUID();
const boundFormId = capability.formBinding?.formId;
return {
eventId,
kind: capability.integration.eventKind,
source: capability.integration.source,
locale,
occurredAt: now.toISOString(),
...(Object.keys(contact).length > 0 ? { contact } : {}),
payload:
typeof boundFormId === "string" && boundFormId.length > 0
? { ...input, formId: boundFormId }
: input,
};
}