clone

a browser-first diagram editor with live svg previews, local drafts and encrypted, accountless sharing

main
diagramzip / services / api / src / validation.ts
validation.ts 10.4K
1import type {2 AliasInput,3 AliasMetadata,4 Diagram,5 EncryptedBlob,6 JsonValue,7 KeyEnvelope,8 OpenAliasInput,9} from './model'1011export const MAX_REQUEST_BYTES = 1024 * 102412export const MAX_SOURCE_BYTES = 768 * 10241314const encoder = new TextEncoder()15const diagramTypePattern = /^[a-z0-9][a-z0-9-]{0,63}$/16const backgroundPattern = /^(?:|#[0-9a-f]{6})$/i17const base64UrlPattern = /^[A-Za-z0-9_-]+$/1819export class RequestError extends Error {20 constructor(21 public readonly status: number,22 public readonly code: string,23 message: string,24 ) {25 super(message)26 }27}2829function isRecord(value: unknown): value is Record<string, unknown> {30 return typeof value === 'object' && value !== null && !Array.isArray(value)31}3233function assertOnlyKeys(value: Record<string, unknown>, allowed: readonly string[], label: string): void {34 const allowedKeys = new Set(allowed)35 if (Object.keys(value).some(key => !allowedKeys.has(key))) {36 throw new RequestError(400, 'invalid_request', `${label} contains an unsupported field.`)37 }38}3940function normalizeJson(value: unknown, depth = 0): JsonValue {41 if (depth > 20) {42 throw new RequestError(400, 'invalid_request', 'Diagram options are nested too deeply.')43 }44 if (value === null || typeof value === 'boolean' || typeof value === 'string') return value45 if (typeof value === 'number' && Number.isFinite(value)) return value46 if (Array.isArray(value)) return value.map(item => normalizeJson(item, depth + 1))47 if (isRecord(value)) {48 const normalized: { [key: string]: JsonValue } = Object.create(null) as { [key: string]: JsonValue }49 for (const key of Object.keys(value).sort()) normalized[key] = normalizeJson(value[key], depth + 1)50 return normalized51 }52 throw new RequestError(400, 'invalid_request', 'Diagram options must contain JSON values.')53}5455function normalizeDiagram(value: unknown): Diagram {56 if (!isRecord(value)) throw new RequestError(400, 'invalid_request', 'diagram must be an object.')57 assertOnlyKeys(value, ['type', 'source', 'options', 'presentation'], 'diagram')58 if (typeof value.type !== 'string' || !diagramTypePattern.test(value.type)) {59 throw new RequestError(400, 'invalid_request', 'diagram.type is invalid.')60 }61 if (typeof value.source !== 'string') {62 throw new RequestError(400, 'invalid_request', 'diagram.source must be a string.')63 }64 if (encoder.encode(value.source).byteLength > MAX_SOURCE_BYTES) {65 throw new RequestError(413, 'request_too_large', 'Diagram source is too large.')66 }67 if (!isRecord(value.options)) {68 throw new RequestError(400, 'invalid_request', 'diagram.options must be an object.')69 }70 const options = normalizeJson(value.options)71 if (!isRecord(options)) throw new RequestError(400, 'invalid_request', 'diagram.options must be an object.')7273 if (!isRecord(value.presentation)) {74 throw new RequestError(400, 'invalid_request', 'diagram.presentation must be an object.')75 }76 assertOnlyKeys(value.presentation, ['background', 'padding', 'frame', 'appearance'], 'diagram.presentation')77 const { background, padding, frame, appearance = 'raw' } = value.presentation78 if (typeof background !== 'string' || !backgroundPattern.test(background)) {79 throw new RequestError(400, 'invalid_request', 'diagram.presentation.background is invalid.')80 }81 if (!Number.isInteger(padding) || (padding as number) < 0 || (padding as number) > 256) {82 throw new RequestError(400, 'invalid_request', 'diagram.presentation.padding is invalid.')83 }84 if (typeof frame !== 'boolean') {85 throw new RequestError(400, 'invalid_request', 'diagram.presentation.frame is invalid.')86 }87 if (typeof appearance !== 'string' || !['raw', 'auto-transparent', 'light-transparent', 'dark-transparent', 'auto-framed', 'light-framed', 'dark-framed'].includes(appearance)) {88 throw new RequestError(400, 'invalid_request', 'diagram.presentation.appearance is invalid.')89 }9091 return {92 type: value.type,93 source: value.source,94 options,95 presentation: { background, padding: padding as number, frame, appearance: appearance as Diagram['presentation']['appearance'] },96 }97}9899function normalizeMetadata(value: unknown): AliasMetadata {100 if (!isRecord(value)) throw new RequestError(400, 'invalid_request', 'metadata must be an object.')101 assertOnlyKeys(value, ['title', 'description'], 'metadata')102 if (typeof value.title !== 'string' || value.title.length > 200) {103 throw new RequestError(400, 'invalid_request', 'metadata.title is invalid.')104 }105 if (typeof value.description !== 'string' || value.description.length > 2000) {106 throw new RequestError(400, 'invalid_request', 'metadata.description is invalid.')107 }108 return { title: value.title, description: value.description }109}110111export function normalizeOpenAliasInput(value: unknown): OpenAliasInput {112 if (!isRecord(value)) throw new RequestError(400, 'invalid_request', 'Request body must be an object.')113 assertOnlyKeys(value, ['mode', 'diagram', 'metadata'], 'Request body')114 if (value.mode !== 'open') {115 throw new RequestError(400, 'unsupported_mode', 'Only open diagrams are supported in this release.')116 }117 return {118 mode: 'open',119 diagram: normalizeDiagram(value.diagram),120 metadata: normalizeMetadata(value.metadata),121 }122}123124export function normalizeEncryptedBlob(value: unknown, label: string): EncryptedBlob {125 if (!isRecord(value)) throw new RequestError(400, 'invalid_request', `${label} must be an object.`)126 assertOnlyKeys(value, ['v', 'alg', 'iv', 'ciphertext'], label)127 if (value.v !== 1 || value.alg !== 'A256GCM') {128 throw new RequestError(400, 'invalid_request', `${label} uses an unsupported encryption format.`)129 }130 if (typeof value.iv !== 'string' || value.iv.length !== 16 || !base64UrlPattern.test(value.iv)) {131 throw new RequestError(400, 'invalid_request', `${label}.iv is invalid.`)132 }133 if (typeof value.ciphertext !== 'string' || value.ciphertext.length < 22 || !base64UrlPattern.test(value.ciphertext)) {134 throw new RequestError(400, 'invalid_request', `${label}.ciphertext is invalid.`)135 }136 return { v: 1, alg: 'A256GCM', iv: value.iv, ciphertext: value.ciphertext }137}138139function normalizeKeyEnvelope(value: unknown): KeyEnvelope {140 if (!isRecord(value)) throw new RequestError(400, 'invalid_request', 'keyEnvelope must be an object.')141 assertOnlyKeys(value, ['v', 'kdf', 'wrap'], 'keyEnvelope')142 if (value.v !== 1 || !isRecord(value.kdf)) {143 throw new RequestError(400, 'invalid_request', 'keyEnvelope uses an unsupported format.')144 }145 assertOnlyKeys(value.kdf, ['name', 'hash', 'iterations', 'salt'], 'keyEnvelope.kdf')146 if (value.kdf.name !== 'PBKDF2' || value.kdf.hash !== 'SHA-256') {147 throw new RequestError(400, 'invalid_request', 'keyEnvelope uses an unsupported KDF.')148 }149 if (!Number.isInteger(value.kdf.iterations)150 || (value.kdf.iterations as number) < 100_000151 || (value.kdf.iterations as number) > 5_000_000) {152 throw new RequestError(400, 'invalid_request', 'keyEnvelope.kdf.iterations is invalid.')153 }154 if (typeof value.kdf.salt !== 'string' || value.kdf.salt.length !== 22 || !base64UrlPattern.test(value.kdf.salt)) {155 throw new RequestError(400, 'invalid_request', 'keyEnvelope.kdf.salt is invalid.')156 }157 return {158 v: 1,159 kdf: {160 name: 'PBKDF2',161 hash: 'SHA-256',162 iterations: value.kdf.iterations as number,163 salt: value.kdf.salt,164 },165 wrap: normalizeEncryptedBlob(value.wrap, 'keyEnvelope.wrap'),166 }167}168169export function normalizeAliasInput(value: unknown): AliasInput {170 if (!isRecord(value)) throw new RequestError(400, 'invalid_request', 'Request body must be an object.')171 if (value.mode === 'open') return normalizeOpenAliasInput(value)172 if (value.mode !== 'locked') {173 throw new RequestError(400, 'unsupported_mode', 'Diagram mode must be open or locked.')174 }175 assertOnlyKeys(value, ['mode', 'encryptedContent', 'encryptedMetadata', 'keyEnvelope'], 'Request body')176 return {177 mode: 'locked',178 encryptedContent: normalizeEncryptedBlob(value.encryptedContent, 'encryptedContent'),179 encryptedMetadata: normalizeEncryptedBlob(value.encryptedMetadata, 'encryptedMetadata'),180 keyEnvelope: normalizeKeyEnvelope(value.keyEnvelope),181 }182}183184export async function readJsonBody(request: Request): Promise<unknown> {185 const contentType = request.headers.get('Content-Type')?.split(';', 1)[0]?.trim().toLowerCase()186 if (contentType !== 'application/json') {187 throw new RequestError(415, 'unsupported_media_type', 'Content-Type must be application/json.')188 }189 const contentEncoding = request.headers.get('Content-Encoding')?.trim().toLowerCase()190 if (contentEncoding && contentEncoding !== 'identity') {191 throw new RequestError(415, 'unsupported_content_encoding', 'Compressed request bodies are not supported.')192 }193 const declaredLength = request.headers.get('Content-Length')194 if (declaredLength !== null) {195 if (!/^[0-9]+$/.test(declaredLength)) {196 throw new RequestError(400, 'invalid_request', 'Content-Length is invalid.')197 }198 const parsedLength = Number(declaredLength)199 if (parsedLength > MAX_REQUEST_BYTES) {200 throw new RequestError(413, 'request_too_large', 'Request body is too large.')201 }202 }203204 const reader = request.body?.getReader()205 const chunks: Uint8Array[] = []206 let length = 0207 if (reader) {208 while (true) {209 const { done, value } = await reader.read()210 if (done) break211 length += value.byteLength212 if (length > MAX_REQUEST_BYTES) {213 await reader.cancel('Request body is too large.')214 throw new RequestError(413, 'request_too_large', 'Request body is too large.')215 }216 chunks.push(value)217 }218 }219 const bytes = new Uint8Array(length)220 let offset = 0221 for (const chunk of chunks) {222 bytes.set(chunk, offset)223 offset += chunk.byteLength224 }225 let body: string226 try {227 body = new TextDecoder('utf-8', { fatal: true, ignoreBOM: false }).decode(bytes)228 } catch {229 throw new RequestError(400, 'invalid_encoding', 'Request body must be valid UTF-8.')230 }231 try {232 return JSON.parse(body) as unknown233 } catch {234 throw new RequestError(400, 'invalid_json', 'Request body is not valid JSON.')235 }236}237238export function canonicalJson(value: unknown): string {239 if (value === null || typeof value !== 'object') return JSON.stringify(value)240 if (Array.isArray(value)) return `[${value.map(item => canonicalJson(item)).join(',')}]`241 const record = value as { [key: string]: unknown }242 return `{${Object.keys(record).sort().map(key => (243 `${JSON.stringify(key)}:${canonicalJson(record[key])}`244 )).join(',')}}`245}