/* * Copyright (C) 2026 Fluxer Contributors * * This file is part of Fluxer. * * Fluxer is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Fluxer is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with Fluxer. If not, see . */ import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes'; import type {ZodTypeAny} from 'zod'; import {z} from 'zod'; export function withOpenApiType(schema: T, typeName: string): T { (schema as Record).__fluxer_custom_type__ = typeName; return schema; } export function withFieldDescription(schema: T, fieldDescription: string): T { const currentDesc = schema.description ?? ''; const newDesc = currentDesc ? `${currentDesc}|fieldDesc:${fieldDescription}` : `|fieldDesc:${fieldDescription}`; return schema.describe(newDesc) as T; } const RTL_OVERRIDE_REGEX = /\u202E/g; // biome-ignore lint/suspicious/noControlCharactersInRegex: this is fine const FORM_FEED_REGEX = /\u000C/g; export const MAX_STRING_PROCESSING_LENGTH = 10_000; export function normalizeString(value: string): string { return ( value .replace(RTL_OVERRIDE_REGEX, '') .replace(FORM_FEED_REGEX, '') // biome-ignore lint/suspicious/noControlCharactersInRegex: null byte and control character filtering is intentional for security .replace(/[\x00-\x09\x0B\x0C\x0E-\x1F\x7F]/g, '') .trim() ); } export const Int64Type = z .union([z.string(), z.number().int()]) .transform((value, ctx) => { if (typeof value === 'number' && !Number.isSafeInteger(value)) { ctx.addIssue({ code: 'custom', message: ValidationErrorCodes.INVALID_INTEGER_FORMAT, }); return z.NEVER; } const normalized = typeof value === 'number' ? value.toString() : value; const trimmed = normalized.trim(); try { const bigInt = BigInt(trimmed); if (bigInt < -9223372036854775808n || bigInt > 9223372036854775807n) { ctx.addIssue({ code: 'custom', message: ValidationErrorCodes.INTEGER_OUT_OF_INT64_RANGE, }); return z.NEVER; } return bigInt; } catch { ctx.addIssue({ code: 'custom', message: ValidationErrorCodes.INVALID_INTEGER_FORMAT, }); return z.NEVER; } }) .describe('fluxer:Int64Type'); export const UnsignedInt64Type = z .union([z.string(), z.number().int()]) .transform((value, ctx) => { if (typeof value === 'number' && !Number.isSafeInteger(value)) { ctx.addIssue({ code: 'custom', message: ValidationErrorCodes.INVALID_INTEGER_FORMAT, }); return z.NEVER; } const normalized = typeof value === 'number' ? value.toString() : value; const trimmed = normalized.trim(); if (!/^\d+$/.test(trimmed)) { ctx.addIssue({ code: 'custom', message: ValidationErrorCodes.INVALID_INTEGER_FORMAT, }); return z.NEVER; } try { const bigInt = BigInt(trimmed); if (bigInt < 0n || bigInt > 9223372036854775807n) { ctx.addIssue({ code: 'custom', message: ValidationErrorCodes.INTEGER_OUT_OF_INT64_RANGE, }); return z.NEVER; } return bigInt; } catch { ctx.addIssue({ code: 'custom', message: ValidationErrorCodes.INVALID_INTEGER_FORMAT, }); return z.NEVER; } }) .describe('fluxer:UnsignedInt64Type'); export const Int64StringType = z .string() .regex(/^-?\d+$/) .describe('fluxer:Int64StringType'); const SNOWFLAKE_REGEX = /^(0|[1-9][0-9]*)$/; const UNSIGNED_INT64_STRING_REGEX = /^\d+$/; export const SnowflakeStringType = z.string().regex(SNOWFLAKE_REGEX).describe('fluxer:SnowflakeStringType'); export const BitflagStringType = z.string().regex(UNSIGNED_INT64_STRING_REGEX).describe('fluxer:BitflagStringType'); const HEX_STRING_16_REGEX = /^[a-f0-9]{16}$/; export const HexString16Type = z.string().regex(HEX_STRING_16_REGEX).describe('fluxer:HexString16Type'); const HEX_STRING_32_REGEX = /^[a-f0-9]{32}$/; export const HexString32Type = z.string().regex(HEX_STRING_32_REGEX).describe('fluxer:HexString32Type'); export const SnowflakeType = z .union([z.string(), z.number().int()]) .transform((value, ctx) => { if (typeof value === 'number' && !Number.isSafeInteger(value)) { ctx.addIssue({ code: 'custom', message: ValidationErrorCodes.INVALID_SNOWFLAKE_FORMAT, }); return z.NEVER; } const normalized = typeof value === 'number' ? value.toString() : value; const trimmed = normalized.trim(); if (!SNOWFLAKE_REGEX.test(trimmed)) { ctx.addIssue({ code: 'custom', message: ValidationErrorCodes.INVALID_SNOWFLAKE_FORMAT, }); return z.NEVER; } try { const bigInt = BigInt(trimmed); if (bigInt < 0n || bigInt > 9223372036854775807n) { ctx.addIssue({ code: 'custom', message: ValidationErrorCodes.SNOWFLAKE_OUT_OF_RANGE, }); return z.NEVER; } return bigInt; } catch { ctx.addIssue({ code: 'custom', message: ValidationErrorCodes.INVALID_SNOWFLAKE_FORMAT, }); return z.NEVER; } }) .describe('fluxer:SnowflakeType'); export const ColorType = z .number() .int() .min(0x000000, ValidationErrorCodes.COLOR_VALUE_TOO_LOW) .max(0xffffff, ValidationErrorCodes.COLOR_VALUE_TOO_HIGH) .describe('fluxer:ColorType'); export const Int32Type = z.number().int().min(0).max(2147483647).describe('fluxer:Int32Type'); export const SignedInt32Type = z.number().int().min(-2147483648).max(2147483647).describe('fluxer:SignedInt32Type'); const INTEGER_STRING_REGEX = /^[+-]?\d+$/; function coerceNumericStringToNumber(value: unknown): unknown { if (typeof value !== 'string') { return value; } const trimmed = value.trim(); if (trimmed.length === 0 || !INTEGER_STRING_REGEX.test(trimmed)) { return value; } const parsed = Number(trimmed); return Number.isNaN(parsed) ? value : parsed; } export function coerceNumberFromString(schema: T) { return z.preprocess((value) => coerceNumericStringToNumber(value), schema); } export function withStringLengthRangeValidation( schema: z.ZodString, minLength: number, maxLength: number, errorCode: string, ) { return schema.superRefine((value, ctx) => { if (value.length < minLength || value.length > maxLength) { const params: Record = {min: minLength, max: maxLength}; if (minLength === maxLength) { params.length = minLength; } ctx.addIssue({code: 'custom', message: errorCode, params}); } }); } export function createStringType(minLength = 1, maxLength = 256) { const errorMessage = minLength === maxLength ? ValidationErrorCodes.STRING_LENGTH_EXACT : ValidationErrorCodes.STRING_LENGTH_INVALID; return z .string() .transform(normalizeString) .pipe(withStringLengthRangeValidation(z.string(), minLength, maxLength, errorMessage)); } export function createUnboundedStringType() { return z.string().transform(normalizeString); } const C0_C1_CTRL_REGEX = // biome-ignore lint/suspicious/noControlCharactersInRegex: this is fine /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F\u0080-\u009F]/g; const JOIN_CONTROLS_REGEX = /(?:\u200C|\u200D)/g; const WJ_BOM_REGEX = /(?:\u2060|\uFEFF)/g; const BIDI_CTRL_REGEX = /[\u200E\u200F\u202A-\u202E\u2066-\u2069]/g; const MISC_INVISIBLES_REGEX = /[\u00AD\u180E\uFFFE\uFFFF]/g; const TAG_CHARS_REGEX = /[\u{E0000}-\u{E007F}]/gu; const VARIATION_SELECTORS_BASIC = /[\uFE00-\uFE0F]/g; const VARIATION_SELECTORS_IDEOGRAPHIC = /[\u{E0100}-\u{E01EF}]/gu; const UNICODE_SPACES_REGEX = /[\s\u00A0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000]/g; export function removeStandaloneSurrogates(value: string): string { return Array.from(value) .filter((char) => { if (char.length > 1) { return true; } const codePoint = char.codePointAt(0); if (codePoint === undefined) { return false; } return codePoint < 0xd800 || codePoint > 0xdfff; }) .join(''); } export function normalizeWhitespace(s: string): string { if (s.length > MAX_STRING_PROCESSING_LENGTH) { throw new Error(ValidationErrorCodes.STRING_LENGTH_INVALID); } return s.replace(UNICODE_SPACES_REGEX, ' ').replace(/\s+/g, ' ').trim(); } export function stripInvisibles(s: string): string { if (s.length > MAX_STRING_PROCESSING_LENGTH) { throw new Error(ValidationErrorCodes.STRING_LENGTH_INVALID); } return s .replace(C0_C1_CTRL_REGEX, '') .replace(JOIN_CONTROLS_REGEX, '') .replace(WJ_BOM_REGEX, '') .replace(BIDI_CTRL_REGEX, '') .replace(MISC_INVISIBLES_REGEX, '') .replace(TAG_CHARS_REGEX, ''); } export function stripVariationSelectors(s: string): string { if (s.length > MAX_STRING_PROCESSING_LENGTH) { throw new Error(ValidationErrorCodes.STRING_LENGTH_INVALID); } return s.replace(VARIATION_SELECTORS_BASIC, '').replace(VARIATION_SELECTORS_IDEOGRAPHIC, ''); } interface EnumEntryJson { n: string; v: string | number; d?: string; } export function createNamedLiteral(value: T, name: string, description?: string) { const entry: EnumEntryJson = {n: name, v: value}; if (description) entry.d = description; return z.literal(value).describe(`fluxer:EnumValue:${JSON.stringify(entry)}`); } export function createNamedLiteralUnion( pairs: ReadonlyArray, description?: string, ) { const literals = pairs.map(([value]) => z.literal(value)); const entries: Array = pairs.map(([value, name, desc]) => { const entry: EnumEntryJson = {n: name, v: value}; if (desc) entry.d = desc; return entry; }); const descPart = description ? ` ${description}` : ''; return z .union(literals as [z.ZodLiteral, z.ZodLiteral, ...Array>]) .describe(`fluxer:EnumValues:${JSON.stringify(entries)}${descPart}`); } export function createNamedStringLiteral(value: T, name: string, description?: string) { const entry: EnumEntryJson = {n: name, v: value}; if (description) entry.d = description; return z.literal(value).describe(`fluxer:EnumValue:${JSON.stringify(entry)}`); } export function createNamedStringLiteralUnion( pairs: ReadonlyArray, description?: string, ) { const literals = pairs.map(([value]) => z.literal(value)); const entries: Array = pairs.map(([value, name, desc]) => { const entry: EnumEntryJson = {n: name, v: value}; if (desc) entry.d = desc; return entry; }); const descPart = description ? ` ${description}` : ''; return z .union(literals as [z.ZodLiteral, z.ZodLiteral, ...Array>]) .describe(`fluxer:EnumValues:${JSON.stringify(entries)}${descPart}`); } export function createFlexibleStringLiteralUnion( pairs: ReadonlyArray, description?: string, ) { const literals = pairs.map(([value]) => z.literal(value)); const entries: Array = pairs.map(([value, name, desc]) => { const entry: EnumEntryJson = {n: name, v: value}; if (desc) entry.d = desc; return entry; }); const descPart = description ? ` ${description}` : ''; const flexibleUnionOperands = [...literals, z.string()] as unknown as [ z.ZodLiteral, z.ZodLiteral, ...Array | z.ZodString>, ]; return z.union(flexibleUnionOperands).describe(`fluxer:FlexibleEnumValues:${JSON.stringify(entries)}${descPart}`); } export function createInt32EnumType( pairs: ReadonlyArray, description?: string, typeName?: string, ) { const entries: Array = pairs.map(([value, name, desc]) => { const entry: EnumEntryJson = {n: name, v: value}; if (desc) entry.d = desc; return entry; }); const typeNamePart = typeName ? `:${typeName}` : ''; const descPart = description ? ` ${description}` : ''; return Int32Type.describe(`fluxer:Int32Enum${typeNamePart}:${JSON.stringify(entries)}${descPart}`); } type BitflagConstantsObject = Readonly>; type BitflagDescriptionsObject = Readonly>>; interface BitflagEntryJson { n: string; v: string; d?: string; } function formatBitflagAnnotation( constants: T, descriptions?: BitflagDescriptionsObject, ): string { const entries: Array = Object.entries(constants) .filter(([, value]) => typeof value === 'number' || typeof value === 'bigint') .map(([name, value]) => { const desc = descriptions?.[name as keyof T]; const entry: BitflagEntryJson = {n: name, v: value.toString()}; if (desc) entry.d = desc; return entry; }); return JSON.stringify(entries); } export function createBitflagStringType( constants: T, descriptionOrDescriptions?: string | BitflagDescriptionsObject, description?: string, typeName?: string, ) { const descriptions = typeof descriptionOrDescriptions === 'object' ? descriptionOrDescriptions : undefined; const overallDescription = typeof descriptionOrDescriptions === 'string' ? descriptionOrDescriptions : description; const annotation = formatBitflagAnnotation(constants, descriptions); const typeNamePart = typeName ? `:${typeName}` : ''; const descPart = overallDescription ? ` ${overallDescription}` : ''; return BitflagStringType.describe(`fluxer:Bitflags64${typeNamePart}:${annotation}${descPart}`); } export function createBitflagInt32Type( constants: T, descriptionOrDescriptions?: string | BitflagDescriptionsObject, description?: string, typeName?: string, ) { const descriptions = typeof descriptionOrDescriptions === 'object' ? descriptionOrDescriptions : undefined; const overallDescription = typeof descriptionOrDescriptions === 'string' ? descriptionOrDescriptions : description; const annotation = formatBitflagAnnotation(constants, descriptions); const typeNamePart = typeName ? `:${typeName}` : ''; const descPart = overallDescription ? ` ${overallDescription}` : ''; return Int32Type.describe(`fluxer:Bitflags32${typeNamePart}:${annotation}${descPart}`); } export function createPermissionStringType( constants: T, descriptionOrDescriptions?: string | BitflagDescriptionsObject, description?: string, typeName?: string, ) { const descriptions = typeof descriptionOrDescriptions === 'object' ? descriptionOrDescriptions : undefined; const overallDescription = typeof descriptionOrDescriptions === 'string' ? descriptionOrDescriptions : description; const annotation = formatBitflagAnnotation(constants, descriptions); const typeNamePart = typeName ? `:${typeName}` : ''; const descPart = overallDescription ? ` ${overallDescription}` : ''; return z .string() .regex(UNSIGNED_INT64_STRING_REGEX) .describe(`fluxer:Permissions${typeNamePart}:${annotation}${descPart}`); }