import type { ErrorCode } from "./error"; /** Server render jobs are deliberately a separate contract from realtime routing. */ export const SERVER_RENDER_JOB_SCHEMA = 1 as const; export const SERVER_RENDER_JOB_BUDGET = { maxSourceBytes: 512 * 1024 * 1024, maxOutputBytes: 512 * 1024 * 1024, maxSettingsBytes: 256 * 1024, maxSettingsNodes: 10_000, maxJobIdBytes: 128, maxBuildVersionBytes: 64, } as const; export const SERVER_RENDER_JOB_STATUSES = ["QUEUED", "RUNNING", "SUCCEEDED", "FAILED", "CANCELLED"] as const; export type ServerRenderJobStatus = typeof SERVER_RENDER_JOB_STATUSES[number]; export interface BlenderBuildIdentityIR { version: string; buildSha256: string; } export interface ServerRenderSettingsIR { renderEngine: "BLENDER_EEVEE" | "BLENDER_EEVEE_NEXT" | "BLENDER_CYCLES" | "BLENDER_WORKBENCH"; frameStart: number; frameEnd: number; resolutionX: number; resolutionY: number; resolutionPercentage: number; samples: number; outputMime: "image/png" | "image/openexr"; transparent: boolean; } export interface ServerRenderJobRequestIR { schemaVersion: typeof SERVER_RENDER_JOB_SCHEMA; jobId: string; sourceBlendSha256: string; sourceBlendByteLength: number; sourceRevision: number; blenderBuild: BlenderBuildIdentityIR; settings: ServerRenderSettingsIR; settingsSha256: string; requestSha256: string; } export interface ServerRenderJobResultIR { schemaVersion: typeof SERVER_RENDER_JOB_SCHEMA; jobId: string; status: ServerRenderJobStatus; sourceBlendSha256: string; sourceBlendByteLength: number; sourceRevision: number; blenderBuild: BlenderBuildIdentityIR; settingsSha256: string; outputMime?: ServerRenderSettingsIR["outputMime"]; outputSha256?: string; outputByteLength?: number; requestSha256: string; resultSha256: string; errorCode?: ErrorCode; } export class ServerRenderJobValidationError extends Error { readonly code: ErrorCode; constructor(code: ErrorCode, message: string) { super(`${code}: ${message}`); this.name = "ServerRenderJobValidationError"; this.code = code; } } const SHA256 = /^[a-f0-9]{64}$/; const JOB_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/; const BUILD_VERSION = /^5\.2\.[0-9]+(?:[-+][A-Za-z0-9.-]+)?$/; const OUTPUT_MIMES = ["image/png", "image/openexr"] as const; function record(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function exactKeys(value: Record, allowed: readonly string[], name: string, code: ErrorCode = "SERVER_RENDER_REQUEST_INVALID"): void { const allowedSet = new Set(allowed); if (Object.keys(value).some((key) => !allowedSet.has(key))) { throw new ServerRenderJobValidationError(code, `${name} contains undeclared fields`); } } function digest(value: unknown, name: string): string { if (typeof value !== "string" || !SHA256.test(value)) { throw new ServerRenderJobValidationError("SERVER_RENDER_HASH_INVALID", `${name} must be a lowercase SHA-256 digest`); } return value; } function boundedInteger(value: unknown, name: string, minimum: number, maximum: number): number { if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum || value > maximum) { throw new ServerRenderJobValidationError("SERVER_RENDER_SETTINGS_INVALID", `${name} is outside the render budget`); } return value; } function stableJSON(value: unknown, state = { nodes: 0, depth: 0 }): string { state.nodes += 1; if (state.nodes > SERVER_RENDER_JOB_BUDGET.maxSettingsNodes) { throw new ServerRenderJobValidationError("SERVER_RENDER_SETTINGS_INVALID", "render settings exceed the node budget"); } if (value === null || typeof value === "string" || typeof value === "boolean") return JSON.stringify(value); if (typeof value === "number" && Number.isFinite(value)) return JSON.stringify(value); if (Array.isArray(value)) { if (++state.depth > 16) throw new ServerRenderJobValidationError("SERVER_RENDER_SETTINGS_INVALID", "render settings are too deeply nested"); const result = `[${value.map((item) => stableJSON(item, state)).join(",")}]`; state.depth -= 1; return result; } if (record(value)) { if (++state.depth > 16) throw new ServerRenderJobValidationError("SERVER_RENDER_SETTINGS_INVALID", "render settings are too deeply nested"); const result = `{${Object.keys(value).sort().map((key) => { if (!/^[A-Za-z][A-Za-z0-9_.-]{0,127}$/.test(key) || value[key] === undefined) { throw new ServerRenderJobValidationError("SERVER_RENDER_SETTINGS_INVALID", `render setting key ${key} is invalid`); } return `${JSON.stringify(key)}:${stableJSON(value[key], state)}`; }).join(",")}}`; state.depth -= 1; return result; } throw new ServerRenderJobValidationError("SERVER_RENDER_SETTINGS_INVALID", "render settings contain a non-JSON value"); } async function sha256(data: ArrayBuffer | string): Promise { const bytes = typeof data === "string" ? new TextEncoder().encode(data) : new Uint8Array(data); const hash = await crypto.subtle.digest("SHA-256", bytes); return Array.from(new Uint8Array(hash), (value) => value.toString(16).padStart(2, "0")).join(""); } function parseBuild(value: unknown): BlenderBuildIdentityIR { if (!record(value) || typeof value.version !== "string" || !BUILD_VERSION.test(value.version) || new TextEncoder().encode(value.version).byteLength > SERVER_RENDER_JOB_BUDGET.maxBuildVersionBytes) { throw new ServerRenderJobValidationError("SERVER_RENDER_BUILD_INVALID", "Blender build version is invalid or outside the 5.2 contract"); } exactKeys(value, ["version", "buildSha256"], "blenderBuild", "SERVER_RENDER_BUILD_INVALID"); return { version: value.version, buildSha256: digest(value.buildSha256, "blenderBuild.buildSha256") }; } function parseSettings(value: unknown): ServerRenderSettingsIR { if (!record(value)) throw new ServerRenderJobValidationError("SERVER_RENDER_SETTINGS_INVALID", "render settings must be an object"); exactKeys(value, ["renderEngine", "frameStart", "frameEnd", "resolutionX", "resolutionY", "resolutionPercentage", "samples", "outputMime", "transparent"], "settings", "SERVER_RENDER_SETTINGS_INVALID"); const renderEngine = value.renderEngine; if (!(["BLENDER_EEVEE", "BLENDER_EEVEE_NEXT", "BLENDER_CYCLES", "BLENDER_WORKBENCH"] as string[]).includes(renderEngine as string)) { throw new ServerRenderJobValidationError("SERVER_RENDER_SETTINGS_INVALID", "render engine is not declared"); } const outputMime = value.outputMime; if (!OUTPUT_MIMES.includes(outputMime as typeof OUTPUT_MIMES[number])) { throw new ServerRenderJobValidationError("SERVER_RENDER_SETTINGS_INVALID", "output MIME is not declared"); } const settings = { ...value, renderEngine, frameStart: boundedInteger(value.frameStart, "frameStart", -1_000_000, 1_000_000), frameEnd: boundedInteger(value.frameEnd, "frameEnd", -1_000_000, 1_000_000), resolutionX: boundedInteger(value.resolutionX, "resolutionX", 1, 16_384), resolutionY: boundedInteger(value.resolutionY, "resolutionY", 1, 16_384), resolutionPercentage: boundedInteger(value.resolutionPercentage, "resolutionPercentage", 1, 100), samples: boundedInteger(value.samples, "samples", 1, 65_536), outputMime, transparent: value.transparent, } as ServerRenderSettingsIR; if (typeof settings.transparent !== "boolean") throw new ServerRenderJobValidationError("SERVER_RENDER_SETTINGS_INVALID", "transparent must be boolean"); if (settings.frameEnd !== settings.frameStart) throw new ServerRenderJobValidationError("SERVER_RENDER_SETTINGS_INVALID", "schema 1 binds one still frame per output hash"); const encoded = new TextEncoder().encode(stableJSON(settings)); if (encoded.byteLength > SERVER_RENDER_JOB_BUDGET.maxSettingsBytes) throw new ServerRenderJobValidationError("SERVER_RENDER_SETTINGS_INVALID", "render settings exceed the byte budget"); return settings; } function canonicalRequest(request: Omit): string { return stableJSON({ schemaVersion: request.schemaVersion, jobId: request.jobId, sourceBlendSha256: request.sourceBlendSha256, sourceBlendByteLength: request.sourceBlendByteLength, sourceRevision: request.sourceRevision, blenderBuild: request.blenderBuild, settingsSha256: request.settingsSha256, }); } function canonicalResult(result: Omit): string { return stableJSON(result); } export async function createServerRenderJobRequest( sourceBlend: ArrayBuffer, blenderBuildValue: unknown, settingsValue: unknown, options: { jobId?: string; sourceRevision?: number } = {}, ): Promise { if (!(sourceBlend instanceof ArrayBuffer) || sourceBlend.byteLength < 1 || sourceBlend.byteLength > SERVER_RENDER_JOB_BUDGET.maxSourceBytes) { throw new ServerRenderJobValidationError("SERVER_RENDER_SOURCE_INVALID", "source .blend bytes are empty or exceed the budget"); } const jobId = options.jobId ?? "render-request"; if (!JOB_ID.test(jobId) || new TextEncoder().encode(jobId).byteLength > SERVER_RENDER_JOB_BUDGET.maxJobIdBytes) { throw new ServerRenderJobValidationError("SERVER_RENDER_REQUEST_INVALID", "jobId is invalid"); } const sourceRevision = options.sourceRevision ?? 0; boundedInteger(sourceRevision, "sourceRevision", 0, Number.MAX_SAFE_INTEGER); const blenderBuild = parseBuild(blenderBuildValue); const settings = parseSettings(settingsValue); const settingsSha256 = await sha256(stableJSON(settings)); const unsigned = { schemaVersion: SERVER_RENDER_JOB_SCHEMA, jobId, sourceBlendSha256: await sha256(sourceBlend), sourceBlendByteLength: sourceBlend.byteLength, sourceRevision, blenderBuild, settings, settingsSha256, } as Omit; return { ...unsigned, requestSha256: await sha256(canonicalRequest(unsigned)) }; } export function parseServerRenderJobRequest(value: unknown): ServerRenderJobRequestIR { if (!record(value) || value.schemaVersion !== SERVER_RENDER_JOB_SCHEMA) throw new ServerRenderJobValidationError("PROTOCOL_MISMATCH", "unsupported server render job request schema"); exactKeys(value, ["schemaVersion", "jobId", "sourceBlendSha256", "sourceBlendByteLength", "sourceRevision", "blenderBuild", "settings", "settingsSha256", "requestSha256"], "request"); const jobId = value.jobId; if (typeof jobId !== "string" || !JOB_ID.test(jobId)) throw new ServerRenderJobValidationError("SERVER_RENDER_REQUEST_INVALID", "jobId is invalid"); const sourceBlendByteLength = boundedInteger(value.sourceBlendByteLength, "sourceBlendByteLength", 1, SERVER_RENDER_JOB_BUDGET.maxSourceBytes); const sourceRevision = boundedInteger(value.sourceRevision, "sourceRevision", 0, Number.MAX_SAFE_INTEGER); const blenderBuild = parseBuild(value.blenderBuild); const settings = parseSettings(value.settings); const settingsSha256 = digest(value.settingsSha256, "settingsSha256"); const requestSha256 = digest(value.requestSha256, "requestSha256"); const request = { schemaVersion: SERVER_RENDER_JOB_SCHEMA, jobId, sourceBlendSha256: digest(value.sourceBlendSha256, "sourceBlendSha256"), sourceBlendByteLength, sourceRevision, blenderBuild, settings, settingsSha256, requestSha256 }; return request; } export async function verifyServerRenderJobRequest(value: unknown, sourceBlend?: ArrayBuffer): Promise { const request = parseServerRenderJobRequest(value); if (await sha256(stableJSON(request.settings)) !== request.settingsSha256) throw new ServerRenderJobValidationError("SERVER_RENDER_SETTINGS_HASH_MISMATCH", "settings do not match settingsSha256"); const { requestSha256, ...unsigned } = request; if (await sha256(canonicalRequest(unsigned)) !== requestSha256) throw new ServerRenderJobValidationError("SERVER_RENDER_REQUEST_HASH_MISMATCH", "request binding hash does not match canonical metadata"); if (sourceBlend !== undefined) { if (!(sourceBlend instanceof ArrayBuffer) || sourceBlend.byteLength !== request.sourceBlendByteLength || await sha256(sourceBlend) !== request.sourceBlendSha256) { throw new ServerRenderJobValidationError("SERVER_RENDER_SOURCE_HASH_MISMATCH", "source .blend bytes do not match the submitted request"); } } return request; } export async function createServerRenderJobResult( requestValue: unknown, output: ArrayBuffer, options: { status?: "SUCCEEDED"; errorCode?: never } = {}, ): Promise { const request = await verifyServerRenderJobRequest(requestValue); if (!(output instanceof ArrayBuffer) || output.byteLength < 1 || output.byteLength > SERVER_RENDER_JOB_BUDGET.maxOutputBytes) { throw new ServerRenderJobValidationError("SERVER_RENDER_OUTPUT_INVALID", "render output is empty or exceeds the budget"); } const unsigned = { schemaVersion: SERVER_RENDER_JOB_SCHEMA, jobId: request.jobId, status: options.status ?? "SUCCEEDED", sourceBlendSha256: request.sourceBlendSha256, sourceBlendByteLength: request.sourceBlendByteLength, sourceRevision: request.sourceRevision, blenderBuild: request.blenderBuild, settingsSha256: request.settingsSha256, outputMime: request.settings.outputMime, outputSha256: await sha256(output), outputByteLength: output.byteLength, requestSha256: request.requestSha256, } as Omit; return { ...unsigned, resultSha256: await sha256(canonicalResult(unsigned)) }; } export async function verifyServerRenderJobResult(value: unknown, requestValue: unknown, output?: ArrayBuffer): Promise { const request = await verifyServerRenderJobRequest(requestValue); if (!record(value) || value.schemaVersion !== SERVER_RENDER_JOB_SCHEMA) throw new ServerRenderJobValidationError("PROTOCOL_MISMATCH", "unsupported server render job result schema"); exactKeys(value, ["schemaVersion", "jobId", "status", "sourceBlendSha256", "sourceBlendByteLength", "sourceRevision", "blenderBuild", "settingsSha256", "outputMime", "outputSha256", "outputByteLength", "requestSha256", "resultSha256", "errorCode"], "result"); const result = { ...value } as unknown as ServerRenderJobResultIR; if (result.jobId !== request.jobId || result.requestSha256 !== request.requestSha256 || result.sourceBlendSha256 !== request.sourceBlendSha256 || result.sourceBlendByteLength !== request.sourceBlendByteLength || result.sourceRevision !== request.sourceRevision || result.settingsSha256 !== request.settingsSha256 || JSON.stringify(result.blenderBuild) !== JSON.stringify(request.blenderBuild)) { throw new ServerRenderJobValidationError("SERVER_RENDER_BINDING_MISMATCH", "server result is bound to different source, build, settings or request"); } if (!SERVER_RENDER_JOB_STATUSES.includes(result.status)) throw new ServerRenderJobValidationError("SERVER_RENDER_RESULT_INVALID", "server result status is invalid"); if (result.status === "SUCCEEDED") { if (result.errorCode !== undefined || result.outputMime !== request.settings.outputMime || !OUTPUT_MIMES.includes(result.outputMime) || typeof result.outputByteLength !== "number" || !Number.isSafeInteger(result.outputByteLength) || result.outputByteLength < 1 || result.outputByteLength > SERVER_RENDER_JOB_BUDGET.maxOutputBytes) { throw new ServerRenderJobValidationError("SERVER_RENDER_OUTPUT_INVALID", "successful result has inconsistent output metadata"); } digest(result.outputSha256, "outputSha256"); if (output !== undefined && (output.byteLength !== result.outputByteLength || await sha256(output) !== result.outputSha256)) { throw new ServerRenderJobValidationError("SERVER_RENDER_OUTPUT_HASH_MISMATCH", "render output bytes do not match outputSha256"); } } else { if (result.outputSha256 !== undefined || result.outputByteLength !== undefined || result.outputMime !== undefined) { throw new ServerRenderJobValidationError("SERVER_RENDER_RESULT_INVALID", "non-successful result must not publish output bytes"); } const expectedErrorCode = result.status === "FAILED" ? "SERVER_RENDER_FAILED" : result.status === "CANCELLED" ? "SERVER_RENDER_CANCELLED" : undefined; if (result.errorCode !== expectedErrorCode) throw new ServerRenderJobValidationError("SERVER_RENDER_RESULT_INVALID", "server result status and errorCode disagree"); } const { resultSha256, ...unsigned } = result; if (typeof resultSha256 !== "string" || !SHA256.test(resultSha256) || await sha256(canonicalResult(unsigned)) !== resultSha256) { throw new ServerRenderJobValidationError("SERVER_RENDER_RESULT_HASH_MISMATCH", "result binding hash does not match canonical metadata"); } return result; } export async function createServerRenderJobKey(requestValue: unknown): Promise { const request = await verifyServerRenderJobRequest(requestValue); const key = await sha256(stableJSON({ schemaVersion: SERVER_RENDER_JOB_SCHEMA, sourceBlendSha256: request.sourceBlendSha256, sourceRevision: request.sourceRevision, blenderBuild: request.blenderBuild, settingsSha256: request.settingsSha256, outputMime: request.settings.outputMime })); return `render-${key}`; }