/** * Advisory descriptor validation (opt-in, on-device). * * Validates the facets the edge app EMITS (reported properties, event payloads, * action returns) against the app's own compiled descriptor schema. Decided * approach: BUILD-TIME EMBED — the app compiles its descriptor `schema.ts` to a * JSON schema at build time (the `template-twin-descriptor` flow), precompiles * validators from it, and hands them to hub-client. See * docs/phy-cli-build-plan.md "Property validation" (spec of record). * * Hard rules this module enforces: * - **Opt-in.** No validator supplied → no validation; behavior is byte-identical * to a non-validating client. * - **Advisory + routing-independent.** On a mismatch we log a warning and the * caller STILL emits/sends/stores. The validation outcome never affects whether * or how a message goes on the wire — an old (non-validating) client and a new * (validating) one behave identically on the wire. * - **Fail-open.** Missing validator for a facet → skip; any error thrown inside * the validation path → caught, logged, and skipped. This module never throws. * * Bundle-size note (device SDK): hub-client ships to devices, so it deliberately * does NOT depend on Ajv or any JSON-schema runtime. The opt-in contract is a set * of precompiled validate functions (the Ajv `(data) => boolean` + `.errors` * shape) that the app produces at build time with the Ajv it already has. Result: * apps that don't opt in pay zero bytes, and apps that do opt in add no validator * to the device bundle — only their own already-present build-time Ajv output. */ /** * A precompiled validate function — the shape Ajv's `compile()` returns. * Returns `true`/`false` and exposes the last run's errors on `.errors`. */ export interface CompiledValidator { (data: unknown): boolean; errors?: unknown; } /** * Per-facet precompiled validators supplied by the edge app. Every field is * optional: a facet with no validator is simply not checked (fail-open). Event * validators are keyed by event type; action-return validators by action name. */ export interface DescriptorValidator { /** Validates the object passed to `updateReported`. */ reported?: CompiledValidator; /** Validates an event payload, keyed by event type (`events..payload`). */ events?: Record; /** Validates an action return value, keyed by action name (`actions..returns`). */ actionReturns?: Record; } export type AdvisoryFacet = 'reported' | 'event' | 'actionReturns'; /** * Logger surface for advisory warnings. Matches what hub-client uses elsewhere * (`console.*`); injectable so tests can assert without spying on the global. */ export interface AdvisoryLogger { warn: (message: string, detail?: unknown) => void; } const defaultLogger: AdvisoryLogger = { warn: (message, detail) => { if (detail === undefined) { console.warn(message); } else { console.warn(message, detail); } }, }; /** * Run a single precompiled validator over `data`, advisory + fail-open. * * NEVER throws and NEVER signals "block this message" — the return value is * purely informational (true = conformed/skipped, false = a mismatch was logged) * and callers MUST emit regardless of it. A `validator` of `undefined` means the * app didn't supply one for this facet → skip (fail-open). Any error thrown by * the validator itself is caught, logged, and treated as a skip (fail-open) so a * broken schema can never block telemetry. * * @param descriptorLabel identifies the descriptor/peripheral in the warning. * @param facet which emitted facet this is, for the warning. * @param facetKey event type or action name, when applicable. */ export function validateEmittedFacet( validator: CompiledValidator | undefined, data: unknown, context: { descriptorLabel: string; facet: AdvisoryFacet; facetKey?: string; logger?: AdvisoryLogger; }, ): boolean { if (!validator) { // Opt-out / no schema for this facet → skip, behave like a non-validating client. return true; } const logger = context.logger ?? defaultLogger; let conforms: boolean; try { conforms = validator(data); } catch (error) { // Fail-open: a broken validator must never block an emit or change routing. logger.warn( `[hub-client] Failed to run advisory descriptor validation for ${describe(context)} — skipping (fail-open)`, error, ); return true; } if (!conforms) { logger.warn( `[hub-client] Advisory descriptor validation mismatch for ${describe(context)} — sending anyway (advisory)`, validator.errors, ); } return conforms; } function describe(context: { descriptorLabel: string; facet: AdvisoryFacet; facetKey?: string }): string { const facetPart = context.facetKey ? `${context.facet} '${context.facetKey}'` : context.facet; return `${facetPart} on '${context.descriptorLabel}'`; }