449 lines
16 KiB
TypeScript
449 lines
16 KiB
TypeScript
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<string, JsonSchema>;
|
|
required?: string[];
|
|
additionalProperties?: boolean | JsonSchema;
|
|
items?: JsonSchema | JsonSchema[];
|
|
minItems?: number;
|
|
maxItems?: number;
|
|
minimum?: number;
|
|
maximum?: number;
|
|
};
|
|
|
|
type SchemaDocument = { definitions?: Record<string, JsonSchema> };
|
|
const schemaDefinitions = (sceneSchemaJson as SchemaDocument).definitions ?? {};
|
|
const ELEMENT_SCHEMA_DEFINITION_BY_TYPE = new Map<string, string>([
|
|
['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<string, unknown> | 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<string, unknown>)
|
|
: null;
|
|
}
|
|
|
|
function hasOnlyAllowedOwnKeys(
|
|
value: Record<string, unknown>,
|
|
allowed: ReadonlySet<string>,
|
|
): boolean {
|
|
return Object.keys(value).every((key) => allowed.has(key));
|
|
}
|
|
|
|
function hasAllRequiredOwnKeys(
|
|
value: Record<string, unknown>,
|
|
required: ReadonlySet<string>,
|
|
): boolean {
|
|
return Array.from(required).every((key) => Object.hasOwn(value, key));
|
|
}
|
|
|
|
function hasExactOwnKeys(value: Record<string, unknown>, expected: ReadonlySet<string>): 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<string>()): 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<object>()): 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<T>(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<string, unknown> = {};
|
|
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<Sha256Digest> {
|
|
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<string>();
|
|
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}`);
|
|
}
|