import { normalizeElement, type PPTElement, type Whiteboard } from '@openmaic/dsl'; import sceneSchemaJson from '@openmaic/dsl/schema/scene.schema.json'; import type { RuntimePayloadValidator } from '@openmaic/storage'; import { LEGACY_WHITEBOARD_SOURCE_KIND, WHITEBOARD_RUNTIME_PAYLOAD_VERSION, type Sha256Digest, type WhiteboardRuntimePayloadV1, } from './types'; const MAX_OPERATION_ID_LENGTH = 512; const MAX_FINGERPRINT_LENGTH = 71; const SAFE_ID = /^[^\u0000-\u001f\u007f\u2028\u2029]+$/u; const SHA256 = /^sha256:[0-9a-f]{64}$/u; const WHITEBOARD_KEYS = new Set([ 'id', 'viewportSize', 'viewportRatio', 'elements', 'background', 'animations', 'script', ]); const REQUIRED_WHITEBOARD_KEYS = new Set(['id', 'viewportSize', 'viewportRatio', 'elements']); const PAYLOAD_KEYS = new Set(['payloadVersion', 'operationId', 'operation']); const LEGACY_OPERATION_KEYS = new Set(['kind', 'source', 'whiteboard']); const ELEMENT_ADDED_OPERATION_KEYS = new Set(['kind', 'element']); const SOURCE_KEYS = new Set(['kind', 'fingerprint']); type JsonSchema = { $ref?: string; anyOf?: JsonSchema[]; type?: string | string[]; const?: unknown; enum?: unknown[]; properties?: Record; required?: string[]; additionalProperties?: boolean | JsonSchema; items?: JsonSchema | JsonSchema[]; minItems?: number; maxItems?: number; minimum?: number; maximum?: number; }; type SchemaDocument = { definitions?: Record }; const schemaDefinitions = (sceneSchemaJson as SchemaDocument).definitions ?? {}; const ELEMENT_SCHEMA_DEFINITION_BY_TYPE = new Map([ ['text', 'PPTTextElement'], ['image', 'PPTImageElement'], ['shape', 'PPTShapeElement'], ['line', 'PPTLineElement'], ['chart', 'PPTChartElement'], ['table', 'PPTTableElement'], ['latex', 'PPTLatexElement'], ['video', 'PPTVideoElement'], ['audio', 'PPTAudioElement'], ['code', 'PPTCodeElement'], ]); const SUPPORTED_SCHEMA_KEYWORDS = new Set([ '$ref', 'anyOf', 'type', 'const', 'enum', 'properties', 'required', 'additionalProperties', 'items', 'minItems', 'maxItems', 'minimum', 'maximum', 'description', 'default', 'title', 'examples', ]); for (const definitionName of [ ...ELEMENT_SCHEMA_DEFINITION_BY_TYPE.values(), 'SlideBackground', 'PPTAnimation', ]) { if (!schemaDefinitions[definitionName]) { throw new Error( `Whiteboard RuntimeStore validator requires generated schema definition ${JSON.stringify(definitionName)}`, ); } } function objectValue(value: unknown): Record | null { if (typeof value !== 'object' || value === null || Array.isArray(value)) return null; const prototype = Object.getPrototypeOf(value); return prototype === Object.prototype || prototype === null ? (value as Record) : null; } function hasOnlyAllowedOwnKeys( value: Record, allowed: ReadonlySet, ): boolean { return Object.keys(value).every((key) => allowed.has(key)); } function hasAllRequiredOwnKeys( value: Record, required: ReadonlySet, ): boolean { return Array.from(required).every((key) => Object.hasOwn(value, key)); } function hasExactOwnKeys(value: Record, expected: ReadonlySet): boolean { return ( Object.keys(value).length === expected.size && hasOnlyAllowedOwnKeys(value, expected) && hasAllRequiredOwnKeys(value, expected) ); } function isSafeIdentifier(value: unknown, maxLength = MAX_OPERATION_ID_LENGTH): value is string { return ( typeof value === 'string' && value.length > 0 && value.length <= maxLength && SAFE_ID.test(value) ); } function decodeRefName(ref: string): string | null { const match = ref.match(/^#\/definitions\/(.+)$/u); return match ? match[1]!.replace(/~1/gu, '/').replace(/~0/gu, '~') : null; } function resolveSchema(schema: JsonSchema, seen = new Set()): JsonSchema { if (!schema.$ref) return schema; const name = decodeRefName(schema.$ref); if (!name || seen.has(name)) return schema; const target = schemaDefinitions[name]; if (!target) return schema; seen.add(name); return resolveSchema(target, seen); } function schemaTypeMatches(value: unknown, type: string): boolean { switch (type) { case 'string': return typeof value === 'string'; case 'number': return typeof value === 'number' && Number.isFinite(value); case 'boolean': return typeof value === 'boolean'; case 'object': return objectValue(value) !== null; case 'array': return Array.isArray(value); case 'null': return value === null; default: return false; } } function validateSchema(value: unknown, input: JsonSchema, path: string): string | null { const schema = resolveSchema(input); if (schema.$ref) return `${path} contains an unresolved schema reference`; if (Object.keys(schema).some((key) => !SUPPORTED_SCHEMA_KEYWORDS.has(key))) { return `${path} uses an unsupported schema construct`; } if (schema.anyOf) { if (schema.anyOf.some((option) => validateSchema(value, option, path) === null)) return null; return `${path} does not match any allowed schema`; } if ('const' in schema && !Object.is(value, schema.const)) { return `${path} must be ${JSON.stringify(schema.const)}`; } if (schema.enum && !schema.enum.some((item) => Object.is(item, value))) { return `${path} is not an allowed value`; } if (schema.type) { const types = Array.isArray(schema.type) ? schema.type : [schema.type]; if (!types.some((type) => schemaTypeMatches(value, type))) { return `${path} has the wrong type`; } } if (typeof value === 'number') { if (!Number.isFinite(value)) return `${path} must be finite`; if (schema.minimum !== undefined && value < schema.minimum) return `${path} is too small`; if (schema.maximum !== undefined && value > schema.maximum) return `${path} is too large`; } if (schema.properties) { const object = objectValue(value); if (!object) return `${path} must be a plain object`; for (const required of schema.required ?? []) { if (!Object.hasOwn(object, required)) return `${path}.${required} is required`; } if (schema.additionalProperties === false) { for (const key of Object.keys(object)) { if (!Object.hasOwn(schema.properties, key)) return `${path}.${key} is out of contract`; } } for (const [key, nested] of Object.entries(schema.properties)) { if (Object.hasOwn(object, key)) { const error = validateSchema(object[key], nested, `${path}.${key}`); if (error) return error; } } } if (schema.items) { if (!Array.isArray(value)) return `${path} must be an array`; if (schema.minItems !== undefined && value.length < schema.minItems) { return `${path} has too few items`; } if (schema.maxItems !== undefined && value.length > schema.maxItems) { return `${path} has too many items`; } if (Array.isArray(schema.items)) { if (value.length !== schema.items.length) return `${path} has the wrong tuple length`; for (let index = 0; index < value.length; index += 1) { const error = validateSchema(value[index], schema.items[index]!, `${path}[${index}]`); if (error) return error; } } else { for (let index = 0; index < value.length; index += 1) { const error = validateSchema(value[index], schema.items, `${path}[${index}]`); if (error) return error; } } } return null; } function validateElement(element: PPTElement): string | null { const definitionName = ELEMENT_SCHEMA_DEFINITION_BY_TYPE.get(element.type); const schema = definitionName ? schemaDefinitions[definitionName] : undefined; return schema ? validateSchema(element, schema, 'element') : 'unknown element type'; } function normalizeAndValidateWhiteboardElement(value: unknown): PPTElement { assertLosslessJson(value, '$'); const normalized = normalizeElement(value as PPTElement); if (!isSafeIdentifier(normalized.id)) throw new Error('invalid element id'); const error = validateElement(normalized); if (error) throw new Error(error); return cloneCanonicalJson(normalized); } function assertLosslessJson(value: unknown, path: string, seen = new Set()): void { if (value === null || typeof value === 'boolean' || typeof value === 'string') { if (typeof value === 'string' && (value.includes('\u0000') || /[\uD800-\uDFFF]/u.test(value))) { throw new Error(`${path} contains a non-portable string`); } return; } if (typeof value === 'number') { if (!Number.isFinite(value) || Object.is(value, -0)) throw new Error(`${path} is not finite JSON`); return; } if (typeof value !== 'object') throw new Error(`${path} is not JSON`); if (seen.has(value)) throw new Error(`${path} is cyclic`); seen.add(value); try { if (Array.isArray(value)) { for (const key of Reflect.ownKeys(value)) { if (key === 'length') continue; const index = typeof key === 'string' ? Number(key) : Number.NaN; if ( !Number.isInteger(index) || index < 0 || index >= value.length || String(index) !== key ) { throw new Error(`${path} has a non-index array property`); } const descriptor = Object.getOwnPropertyDescriptor(value, key); if (descriptor?.get || descriptor?.set) { throw new Error(`${path}[${String(key)}] is not a plain data property`); } } for (let index = 0; index < value.length; index += 1) { if (!Object.hasOwn(value, index)) throw new Error(`${path}[${index}] is sparse`); assertLosslessJson(value[index], `${path}[${index}]`, seen); } return; } const object = objectValue(value); if (!object) throw new Error(`${path} is not a plain object`); for (const key of Reflect.ownKeys(object)) { if (typeof key !== 'string') throw new Error(`${path} has a symbol key`); if (key.includes('\u0000') || /[\uD800-\uDFFF]/u.test(key)) { throw new Error(`${path} has a non-portable object key`); } const descriptor = Object.getOwnPropertyDescriptor(object, key); if (!descriptor?.enumerable || descriptor.get || descriptor.set) { throw new Error(`${path}.${key} is not a plain data property`); } assertLosslessJson(object[key], `${path}.${key}`, seen); } } finally { seen.delete(value); } } export function cloneCanonicalJson(value: T): T { assertLosslessJson(value, '$'); const clone = (input: unknown): unknown => { if (Array.isArray(input)) return input.map(clone); const object = objectValue(input); if (!object) return input; const output: Record = {}; for (const key of Object.keys(object).sort()) { Object.defineProperty(output, key, { value: clone(object[key]), enumerable: true, writable: true, configurable: true, }); } return output; }; return clone(value) as T; } export function canonicalJson(value: unknown): string { return JSON.stringify(cloneCanonicalJson(value)); } export async function sha256Canonical(value: unknown): Promise { if (!globalThis.crypto?.subtle) throw new Error('SHA-256 requires Web Crypto'); const bytes = new TextEncoder().encode(canonicalJson(value)); const digest = new Uint8Array(await globalThis.crypto.subtle.digest('SHA-256', bytes)); return `sha256:${Array.from(digest, (byte) => byte.toString(16).padStart(2, '0')).join('')}`; } export function normalizeAndValidateLegacyWhiteboard(value: unknown): Whiteboard { assertLosslessJson(value, '$'); const board = objectValue(value); if ( !board || !hasOnlyAllowedOwnKeys(board, WHITEBOARD_KEYS) || !hasAllRequiredOwnKeys(board, REQUIRED_WHITEBOARD_KEYS) ) throw new Error('invalid whiteboard envelope'); if (!isSafeIdentifier(board.id)) throw new Error('invalid whiteboard id'); if (typeof board.viewportSize !== 'number' || !Number.isFinite(board.viewportSize)) { throw new Error('invalid whiteboard viewportSize'); } if (typeof board.viewportRatio !== 'number' || !Number.isFinite(board.viewportRatio)) { throw new Error('invalid whiteboard viewportRatio'); } if (!Array.isArray(board.elements)) throw new Error('invalid whiteboard elements'); if (Object.hasOwn(board, 'background')) { const error = validateSchema( board.background, schemaDefinitions.SlideBackground ?? {}, 'background', ); if (error) throw new Error(error); } if (Object.hasOwn(board, 'animations')) { if (!Array.isArray(board.animations)) throw new Error('invalid whiteboard animations'); for (const animation of board.animations) { const error = validateSchema(animation, schemaDefinitions.PPTAnimation ?? {}, 'animation'); if (error) throw new Error(error); } } if (Object.hasOwn(board, 'script') && typeof board.script !== 'string') { throw new Error('invalid whiteboard script'); } const ids = new Set(); const elements = board.elements.map((candidate) => { const normalized = normalizeAndValidateWhiteboardElement(candidate); if (ids.has(normalized.id)) throw new Error('duplicate element id'); ids.add(normalized.id); return normalized; }); return cloneCanonicalJson({ ...board, elements } as Whiteboard); } export function validateWhiteboardRuntimePayload( payload: unknown, ): { valid: true } | { valid: false; errors: { path: string; message: string }[] } { try { assertLosslessJson(payload, '$'); const value = objectValue(payload); if (!value || !hasExactOwnKeys(value, PAYLOAD_KEYS)) { throw new Error('payload keys are invalid'); } if (value.payloadVersion !== WHITEBOARD_RUNTIME_PAYLOAD_VERSION) { throw new Error('payloadVersion must be 1'); } if (!isSafeIdentifier(value.operationId)) throw new Error('operationId is invalid'); const operation = objectValue(value.operation); if (!operation) throw new Error('operation is invalid'); if (operation.kind === 'legacy_snapshot_imported') { if (!hasExactOwnKeys(operation, LEGACY_OPERATION_KEYS)) { throw new Error('operation is invalid'); } const source = objectValue(operation.source); if (!source || !hasExactOwnKeys(source, SOURCE_KEYS)) throw new Error('source is invalid'); if (source.kind !== LEGACY_WHITEBOARD_SOURCE_KIND) throw new Error('source kind is invalid'); if ( typeof source.fingerprint !== 'string' || source.fingerprint.length !== MAX_FINGERPRINT_LENGTH || !SHA256.test(source.fingerprint) ) { throw new Error('source fingerprint is invalid'); } const normalized = normalizeAndValidateLegacyWhiteboard(operation.whiteboard); if (canonicalJson(normalized) !== canonicalJson(operation.whiteboard)) { throw new Error('whiteboard payload is not canonical'); } } else if (operation.kind === 'element_added') { if (!hasExactOwnKeys(operation, ELEMENT_ADDED_OPERATION_KEYS)) { throw new Error('operation is invalid'); } const normalized = normalizeAndValidateWhiteboardElement(operation.element); if (canonicalJson(normalized) !== canonicalJson(operation.element)) { throw new Error('element payload is not canonical'); } } else { throw new Error('operation kind is invalid'); } return { valid: true }; } catch (error) { return { valid: false, errors: [ { path: '/payload', message: error instanceof Error ? error.message : String(error) }, ], }; } } export const whiteboardRuntimePayloadValidator: RuntimePayloadValidator = validateWhiteboardRuntimePayload; export function assertWhiteboardRuntimePayload( payload: unknown, ): asserts payload is WhiteboardRuntimePayloadV1 { const result = validateWhiteboardRuntimePayload(payload); if (!result.valid) throw new Error(`Invalid whiteboard runtime payload: ${result.errors[0]?.message}`); }