You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

54 lines
3.0 KiB

import { readFile } from 'node:fs/promises';
import { z } from 'zod';
const schema = JSON.parse(await readFile(new URL('schema.json', import.meta.url), 'utf8'));
const validators = new Map<string, z.ZodType>();
// Contract tooling. Browser/native SDKs need their own bounded decoder.
export function validateModel(name: string, value: unknown): unknown {
if (!(name in schema.$defs)) throw new Error(`Unknown shared model: ${name}`);
let validator = validators.get(name);
if (!validator) {
validator = z.fromJSONSchema({ ...schema, $ref: `#/$defs/${name}` });
validators.set(name, validator);
}
const result = validator.parse(value);
validateRelations(value, name);
return result;
}
function validateRelations(value: unknown, name: string): void {
if (!value || typeof value !== 'object') return;
const object = value as Record<string, unknown>;
if (name === 'ErrorData') {
const recovery = object.recovery as Record<string, unknown>;
if (object.outcome === 'unknown' && recovery.action !== 'resolve-pending')
throw new Error('Unknown outcome must resolve the pending operation');
if (object.outcome === 'rejected' && recovery.action === 'retry-same-request')
throw new Error('A confirmed rejection must not automatically retry');
if ('retryAfterMs' in recovery && !['retry-same-request', 'resolve-pending'].includes(String(recovery.action)))
throw new Error('Retry delay requires a retry/status recovery');
}
if (name === 'CommandRejection' && ((object.error as Record<string, unknown>).outcome !== 'rejected' || (object.error as Record<string, unknown>).origin !== 'server'))
throw new Error('A rejection receipt needs a confirmed server rejection');
if (name === 'GameCard' && Number(object.minPlayers) > Number(object.maxPlayers))
throw new Error('Player limits are reversed');
if (name === 'HttpProblem' || name === 'ErrorMessagePayload') {
const error = object.error as Record<string, unknown>;
const correlation = object.correlation as Record<string, unknown>;
if (error.outcome === 'unknown' && !correlation.commandId && !correlation.operationId)
throw new Error('Unknown outcome needs an operation/command reference');
if ('status' in object && error.origin !== 'server')
throw new Error('An HTTP problem must carry a server-origin error');
if ('type' in object && String(object.type) !== `urn:juegoland:problem:${String(error.code).toLowerCase().replaceAll('_', '-')}`)
throw new Error('Problem type/code mismatch');
}
// Ignore optional fields unknown to this version; do not interpret their names.
for (const [key, child] of Object.entries(schema.$defs[name].properties ?? {}) as [string, { $ref?: string; type?: string; items?: { $ref?: string } }][]) {
if (child.$ref) validateRelations(object[key], child.$ref.split('/').at(-1)!);
if (child.type === 'array' && child.items?.$ref) {
for (const item of object[key] as unknown[]) validateRelations(item, child.items.$ref.split('/').at(-1)!);
}
}
}

Powered by TurnKey Linux.